[eric] agents: pool state typed end to end (protocol attrs + SdkClientLike casts), pyright clean

This commit is contained in:
ciregenz
2026-07-28 11:06:53 -07:00
parent 4228cf5d00
commit e498484409
4 changed files with 27 additions and 9 deletions
+4 -2
View File
@@ -29,6 +29,8 @@ from backend.apps.agents.manager.MockAgent import MockAgent
from backend.apps.agents.manager.RunSupport import RunSupport
from backend.apps.agents.manager.run.handle_run_error import handle_run_error
from backend.apps.agents.manager.run.TurnRunner import TurnRunner
from backend.apps.agents.manager.run.client_pool import ClientHandle
from backend.apps.agents.manager.streaming.HookContext import HookContext
from backend.apps.agents.manager.run.RunOptions import RunOptions
logger = logging.getLogger(__name__)
@@ -49,9 +51,9 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
# Per-session cancel signal: the loop stashes its asyncio.Event here so a stop/close can set it. Lives on the manager, not the AgentSession model, so it stays out of serialization (an Event can't be model_dump'd).
self.cancel_events: Dict[str, asyncio.Event] = {}
# Persistent-client pool (lever A, flag-gated): one live CLI per session, reused across turns.
self.client_pool: Dict[str, object] = {}
self.client_pool: Dict[str, ClientHandle] = {}
# Per-SESSION hook context + stderr buffer, updated in place each turn: a persistent client's hooks/stderr callback were bound at connect, so they must read stable objects, not per-turn rebuilds.
self.hook_ctxs: Dict[str, object] = {}
self.hook_ctxs: Dict[str, HookContext] = {}
self.stderr_buffers: Dict[str, List[str]] = {}
# Admission gate: one shared semaphore caps concurrent ROOT turns (children bypass). (Re)created per running loop by get_turn_admission so it never binds to a dead loop across a uvicorn reload or a test's asyncio.run.
self.p_turn_admission_sema: Optional[asyncio.Semaphore] = None
@@ -13,10 +13,12 @@ just sits once in the MRO. Re-enables the linter's pyright reportAttributeAccess
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, Any, Dict
from typing import TYPE_CHECKING, Any, Dict, List
if TYPE_CHECKING:
from backend.apps.agents.core.models import AgentSession
from backend.apps.agents.manager.run.client_pool import ClientHandle
from backend.apps.agents.manager.streaming.HookContext import HookContext
from backend.apps.agents.manager.streaming.PartialReply import PartialReply
@@ -26,6 +28,9 @@ class AgentManagerProtocol:
tasks: Dict[str, asyncio.Task]
live_partial: Dict[str, PartialReply]
cancel_events: Dict[str, asyncio.Event]
client_pool: Dict[str, ClientHandle]
hook_ctxs: Dict[str, HookContext]
stderr_buffers: Dict[str, List[str]]
if TYPE_CHECKING:
# Methods implemented on sibling mixins / AgentManager itself and called cross-mixin. Loose signatures on purpose: typeCheckingMode is off, so this only has to assert the names exist, not pin their call shapes.
@@ -6,7 +6,7 @@ except-handlers can still read them after a mid-stream failure."""
import asyncio
import logging
import time
from typing import Dict, List, Union
from typing import Dict, List, Union, cast
from typeguard import typechecked
from backend.apps.agents.core.models import AgentSession
@@ -17,6 +17,7 @@ from backend.apps.agents.manager.streaming.handle_stream_event import handle_str
from backend.apps.agents.manager.streaming.handle_assistant_message import handle_assistant_message
from backend.apps.agents.manager.streaming.handle_result_message import handle_result_message
from backend.apps.agents.manager.run.client_pool import (
SdkClientLike,
acquire_client,
boot_fingerprint,
dispose_client,
@@ -132,8 +133,9 @@ class TurnRunner(AgentManagerProtocol):
async with handle.lock:
handle.turns_served += 1
try:
await handle.client.query(prompt_stream())
await p_run_streaming_turn(p_stream=handle.client.receive_response())
sdk = cast(SdkClientLike, handle.client)
await sdk.query(prompt_stream())
await p_run_streaming_turn(p_stream=sdk.receive_response())
# LRU by turn-END so a session mid-long-turn isn't first cap-evicted the instant it finishes.
handle.last_used = time.monotonic()
except BaseException:
+12 -3
View File
@@ -14,7 +14,7 @@ import json
import logging
import os
import time
from typing import Awaitable, Callable, Dict, List, Optional
from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Optional, Protocol, cast
from pydantic import BaseModel, ConfigDict, InstanceOf
from typeguard import typechecked
@@ -58,6 +58,15 @@ def boot_fingerprint(options_kwargs: Dict, session: AgentSession) -> str:
return hashlib.sha256(blob.encode()).hexdigest()
class SdkClientLike(Protocol):
"""The slice of claude_agent_sdk.ClaudeSDKClient the pool touches. The real class can't be
module-imported here (mock-mode must import the manager without the SDK), so callers cast."""
async def query(self, prompt: Any) -> None: ...
def receive_response(self) -> AsyncIterator[Any]: ...
async def disconnect(self) -> None: ...
class ClientHandle(BaseModel):
model_config = ConfigDict(validate_assignment=True)
@@ -150,7 +159,7 @@ async def dispose_client(pool: Dict[str, ClientHandle], session_id: str) -> None
if handle is None:
return
try:
await handle.client.disconnect()
await cast(SdkClientLike, handle.client).disconnect()
except Exception:
logger.exception(f"[client-pool] {session_id}: disconnect failed (subprocess may already be dead)")
@@ -164,7 +173,7 @@ def dispose_client_soon(pool: Dict[str, ClientHandle], session_id: str) -> None:
async def p_bg() -> None:
try:
await handle.client.disconnect()
await cast(SdkClientLike, handle.client).disconnect()
except Exception:
logger.exception(f"[client-pool] {session_id}: background disconnect failed")