diff --git a/.omo/plans/fix-mcp-session-lifecycle.md b/.omo/plans/fix-mcp-session-lifecycle.md deleted file mode 100644 index 55de89956..000000000 --- a/.omo/plans/fix-mcp-session-lifecycle.md +++ /dev/null @@ -1,407 +0,0 @@ -# fix-mcp-session-lifecycle - Work Plan - -## TL;DR (For humans) - -**What you'll get:** MCP resources (toolsets, transports, ACP connections) are properly cleaned up when sessions close or WebSocket connections drop, eliminating stale toolset references on session resume. This is Phase 1 of a 2-phase MCP lifecycle redesign. - -**Why this approach:** The root cause is that session-scoped MCP state is scattered across 4 objects (`_toolset_cache`, `_session_connection_pool`, `_mcp_snapshot`, `AcpMcpConnectionManager._connections`) with no coordinated cleanup. The fix centralizes session-scoped state into `_SessionContext` on `MCPManager`, adds a per-session `asyncio.Lock` for concurrency-safe idempotent `cleanup_session()`, and wires cleanup into all 3 close paths (ACPSession.close, SessionController.close_session, WebSocket disconnect hook). All 8 design decisions were made through 3 rounds of Oracle + Gemini review. - -**What it will NOT do:** No changes to MessageNode base class, AgentPool registry, MCPResourceProvider model, or config API. No Phase 2 features (per-agent MCPManager removal, allow/block lists, pool-level MCP consolidation). No ACP v2 migration. - -**Effort:** Large -**Risk:** Medium — touches 7 source files across 3 subsystems (MCP manager, ACP server, session orchestrator), but all design decisions are made and codebase state is verified -**Decisions to sanity-check:** D4 (as_capability(session_id) API change — only caller is get_agentlet), D5 (reverse index at manager level instead of changing _session_streams key type), D6 (two-layer close-then-recreate in resume_session) - -Your next move: approve to start work, or run a high-accuracy review first. Full execution detail follows below. - ---- - -> TL;DR (machine): Large effort, Medium risk, 33 todos across 7 waves — fix MCP session lifecycle by centralizing session-scoped state in _SessionContext, wiring cleanup into all close paths, and fixing resume_session. - -## Scope -### Must have -- `_SessionContext` dataclass on `MCPManager` with per-session `toolset_cache`, `connection_pool`, `snapshot`, `acp_connection_ids`, and `_cleanup_lock` -- `MCPManager.get_or_create_session()`, `update_session_snapshot()`, `add_acp_transport()`, `cleanup_session()` methods -- `as_capability(session_id: str | None = None)` simplified API replacing `as_capability(snapshot=..., session_pool=...)` -- `_session_connections: dict[str, set[tuple[str, int]]]` reverse index on `AcpMcpConnectionManager` with `register_session_connection()` and `cleanup_session()` methods -- `has_active_sessions()` on `AcpMcpConnection` -- `cleanup_session()` wired into `ACPSession.close()` and `SessionController._close_session_run_turn()` -- `_session_id` stored on `Agent` and propagated to `as_capability()` call in `get_agentlet()` -- `resume_session()` close-then-recreate via two-layer cleanup (SessionController.close_session + ACPSession.close) -- `on_disconnect` callback in `_handle_websocket_client()` + `close_all_sessions_for_connection()` on `ACPSessionManager` -- All existing bug-documenting tests flipped to fix-verifying tests -- Full unit + integration test coverage for all new code paths - -### Must NOT have (guardrails, anti-slop, scope boundaries) -- NO changes to `MessageNode` base class (`messagenode.py`) -- NO changes to `AgentPool` registry (`pool.py`) -- NO removal of per-agent MCPManager (Phase 2) -- NO config API changes — no new YAML fields, no new public config models -- NO changes to `MCPResourceProvider` model or `ResourceProvider` base class -- NO Phase 2 features (allow/block lists, pool-level MCP consolidation, skill MCP dual path consolidation) -- NO ACP v2 protocol migration -- NO `getattr`/`hasattr` — full type safety with match/case or isinstance -- NO TODOs left in code - -### Metis gap resolutions (folded into todos below) -1. **GAP-1 (Critical)**: `AcpMcpConnection.register_session()` currently returns `SessionStreamPair`, NOT an int key. **Resolution**: Modify `register_session()` to return `tuple[SessionStreamPair, int]` — the pair AND the internal `_next_session_key`. Callers store the int key in `acp_connection_ids`. Affected: T1, T3, T6, T7, T8. -2. **GAP-3 (Critical)**: `AgentSideConnection` has no `connection_id` for `_connection_sessions` lookup. **Resolution**: Generate a UUID4 string for each WebSocket connection at accept time, store it on the `AgentSideConnection` instance (add a `connection_id: str` attribute set in `_handle_websocket_client()`), and use that as the key in `_connection_sessions`. The `on_disconnect` callback receives the `AgentSideConnection` and reads `.connection_id`. Affected: T24, T25, T26. -3. **GAP-4 (High)**: `_session_id` storage location undefined. **Resolution**: Use `run_ctx.session_id` in `get_agentlet()` (already available via `AgentRunContext`) instead of storing `self._session_id` on Agent. This avoids duplicating state. If `run_ctx` is None or `run_ctx.session_id` is None, fall back to `session_id=None` (global-only capabilities). Affected: T12, T16. -4. **GAP-5 (High)**: `connect_acp_mcp_server()` signature must gain `session_id: str` parameter. **Resolution**: Change signature to `connect_acp_mcp_server(self, server: AcpMcpServer, session_id: str) -> str`. Update call site at `session.py:478`. Affected: T8. -5. **GAP-7 (Medium)**: `_make_capability()` is a closure inside `as_capability()` and can't access per-session cache. **Resolution**: Pass `toolset_cache: dict[str, Any]` as a parameter to `_make_capability()` instead of accessing `self._toolset_cache` directly. When `cache=True`, pass `self._toolset_cache`; when `cache=False`, pass `ctx.toolset_cache`. Affected: T10. -6. **GAP-11 (High)**: Race condition — `cleanup_session()` can pop context while `as_capability()` reads it. **Resolution**: `as_capability()` acquires `ctx._cleanup_lock` before reading the session context (shared lock via `asyncio.Lock` — but `asyncio.Lock` is exclusive, not shared). Alternative: `as_capability()` catches `KeyError` on `_session_contexts` lookup and falls back to global-only. **Chosen**: Catch `KeyError` fallback approach — simpler, no lock contention. Affected: T10. -7. **GAP-12 (High)**: `AcpMcpConnectionManager.cleanup_session()` has no lock. **Resolution**: Add `_cleanup_lock: asyncio.Lock` to `AcpMcpConnectionManager.__init__`. `cleanup_session()` acquires it. Double-cleanup from `MCPManager.cleanup_session()` + direct call is idempotent (pop from `_session_connections` returns None on second call). Affected: T7. -8. **GAP-15 (High)**: Task 7.5 manual test violates zero-user-intervention. **Resolution**: Replace with automated integration test using mock ACP client. Test creates a mock WebSocket connection, sends ACP messages, simulates disconnect, reconnects, and verifies MCP tools work. Affected: T33. -9. **GAP-14 (Medium)**: Resume after close — closed session re-opening. **Resolution**: `SessionController.close_session()` marks session as closed in store (line 830-832) but `_get_or_create_session_locked()` creates fresh `SessionState` if not in `_sessions` dict (which was popped at line 933). The store's "closed" flag is informational — a new `SessionState` is created. This is validated by the existing resume flow. No change needed, but T20 acceptance criteria must verify this explicitly. - -## Verification strategy -> Zero human intervention - all verification is agent-executed. -- Test decision: tests-after (implementation first, tests in same todo) + pytest -- Evidence: .omo/evidence/task--fix-mcp-session-lifecycle. -- Lint: `uv run ruff check src/` — zero errors -- Types: `uv run --no-group docs mypy src/` — zero errors on changed files -- Tests: `uv run pytest tests/mcp_server/ tests/agentpool_server/acp_server/ -v` -- Unit marker: `uv run pytest -m unit` - -## Execution strategy -### Parallel execution waves -> Target 5-8 todos per wave. Fewer than 3 (except the final) means under-splitting. - -### Dependency matrix -| Todo | Depends on | Blocks | Can parallelize with | -| --- | --- | --- | --- | -| T1 (_SessionContext + _session_contexts) | — | T2, T3, T4, T5, T10, T15 | T6, T7, T8, T9 | -| T2 (get_or_create_session + update_session_snapshot) | T1 | T4, T10, T16 | T3, T6, T7, T8, T9 | -| T3 (add_acp_transport) | T1 | T5, T8 | T2, T6, T7, T8, T9 | -| T4 (cleanup_session with lock) | T1, T2 | T15, T17, T19, T22 | T5, T6, T7, T8, T9 | -| T5 (session context unit tests) | T1-T4 | — | T6, T7, T8, T9 | -| T6 (_session_connections + register_session_connection) | — | T7, T8, T15 | T1-T5, T9 | -| T7 (AcpMcpConnectionManager.cleanup_session + has_active_sessions) | T6 | T15, T17, T19, T22 | T1-T5, T8, T9 | -| T8 (Wire register_session_connection into connect_acp_mcp_server) | T3, T6 | T15 | T1-T5, T7, T9 | -| T9 (ACP session connection unit tests) | T6, T7, T8 | — | T1-T5 | -| T10 (as_capability new signature + _make_capability + _process_snapshot) | T1, T2 | T11, T12, T13, T14 | — | -| T11 (session-scoped vs global routing) | T10 | T12 | T13, T14 | -| T12 (Update get_agentlet call site) | T10, T11 | T15, T16 | T13, T14 | -| T13 (Update test_mcpmanager_caching.py) | T10 | — | T14 | -| T14 (Flip test_stale_mcp_connection.py to fix-verifying) | T10 | — | T13 | -| T15 (Wire cleanup into ACPSession.close + SessionController) | T4, T7, T8, T12 | T17, T18, T19, T20, T22 | T16 | -| T16 (get_or_create_session_agent + _session_id on Agent) | T2, T12 | T17, T18 | T15 | -| T17 (Integration: create→run→close→verify empty) | T15, T16 | — | T18, T19 | -| T18 (Integration: close→recreate same ID→fresh resources) | T15, T16 | — | T17, T19 | -| T19 (Test: concurrent cleanup_session calls) | T4, T15 | T22 | T17, T18 | -| T20 (Fix resume_session close-then-recreate) | T15, T16 | T21, T22, T23 | — | -| T21 (Test: resume→old closed→fresh MCP) | T20 | — | T22, T23 | -| T22 (Test: resume after WebSocket reconnect) | T15, T19, T20 | — | T21, T23 | -| T23 (Test: resume with active run→RunHandle cancelled) | T20 | — | T21, T22 | -| T24 (on_disconnect param + ConnectionClosed hook) | T15 | T25, T26, T27 | — | -| T25 (_connection_sessions + close_all_sessions_for_connection) | T15, T24 | T26, T28 | — | -| T26 (Wire on_disconnect in server setup) | T24, T25 | T27, T28 | — | -| T27 (Tests: disconnect closes + other connections unaffected) | T25, T26 | — | T28 | -| T28 (Test: disconnect during active run→RunHandle cancelled) | T25, T26 | — | T27 | -| T29-T33 (End-to-end verification) | ALL | — | — | - -## Todos -> Implementation + Test = ONE todo. Never separate. - -### Wave 1: P1a — MCPManager Session Tracking (foundation) - -- [x] 1. Add `_SessionContext` dataclass and `_session_contexts` dict to MCPManager - What to do / Must NOT do: Create a `@dataclass` named `_SessionContext` with fields: `connection_pool: SessionConnectionPool`, `toolset_cache: dict[str, Any]`, `snapshot: McpConfigSnapshot | None`, `acp_connection_ids: list[tuple[str, int]]`, `_cleanup_lock: asyncio.Lock`. Add `_session_contexts: dict[str, _SessionContext]` to `MCPManager.__init__` (after `_toolset_cache` at line 147). Import `SessionConnectionPool` from `agentpool.mcp_server.session_pool`, `McpConfigSnapshot` from `agentpool.mcp_server.config_snapshot`. **Metis GAP-1 resolution**: `AcpMcpConnection.register_session()` currently returns `SessionStreamPair` only — it must be modified (in T7) to return `tuple[SessionStreamPair, int]` so the int key can be stored in `acp_connection_ids`. Must NOT remove or rename existing `_toolset_cache` (D3: retained for global configs). - Parallelization: Wave 1 | Blocked by: — | Blocks: T2, T3, T4, T5 - References: `src/agentpool/mcp_server/manager.py:115` (MCPManager class), `manager.py:123-147` (__init__ fields), `manager.py:147` (_toolset_cache line), `src/agentpool/mcp_server/session_pool.py` (SessionConnectionPool class with `cleanup(timeout=5.0)` method and `copy_pre_created_transports()`), `src/agentpool/mcp_server/config_snapshot.py` (McpConfigSnapshot frozen dataclass with `pool_configs`, `agent_configs`, `session_configs`, `skill_configs` fields and `global_configs`/`session_scoped_configs` properties) - Acceptance criteria: `uv run python -c "from agentpool.mcp_server.manager import MCPManager, _SessionContext; print(_SessionContext.__dataclass_fields__.keys())"` prints fields including `connection_pool`, `toolset_cache`, `snapshot`, `acp_connection_ids`, `_cleanup_lock`. `uv run ruff check src/agentpool/mcp_server/manager.py` passes. - QA scenarios: (happy) `uv run python -c "import asyncio; from agentpool.mcp_server.manager import _SessionContext; ctx = _SessionContext(connection_pool=None, toolset_cache={}, snapshot=None, acp_connection_ids=[], _cleanup_lock=asyncio.Lock()); print(ctx)"` runs without error. (failure) Verify `MCPManager()` has `_session_contexts` attribute initialized as empty dict. Evidence: `.omo/evidence/task-1-fix-mcp-session-lifecycle.txt` - Commit: Y | feat(mcp): add _SessionContext dataclass and _session_contexts to MCPManager - -- [x] 2. Implement `get_or_create_session()` and `update_session_snapshot()` on MCPManager - What to do / Must NOT do: Add `get_or_create_session(self, session_id: str) -> _SessionContext` — if `session_id` not in `_session_contexts`, create new `_SessionContext` with fresh `SessionConnectionPool()`, empty `toolset_cache`, `snapshot=None`, empty `acp_connection_ids`, new `asyncio.Lock()`. Return existing if present. Add `update_session_snapshot(self, session_id: str, snapshot: McpConfigSnapshot) -> None` — calls `get_or_create_session(session_id)` then sets `.snapshot = snapshot`. Must NOT raise if session already exists (idempotent). - Parallelization: Wave 1 | Blocked by: T1 | Blocks: T4, T10, T16 - References: `src/agentpool/mcp_server/manager.py:115` (class), `session_pool.py` (SessionConnectionPool constructor — check if it takes args), `config_snapshot.py` (McpConfigSnapshot type) - Acceptance criteria: `uv run pytest tests/mcp_server/test_session_lifecycle.py -k "test_get_or_create_session" -v` passes (test added in T5). `uv run ruff check src/agentpool/mcp_server/manager.py` passes. - QA scenarios: (happy) Create manager, call `get_or_create_session("s1")` twice, verify same object returned. (failure) Call `update_session_snapshot` on non-existent session, verify it creates the context. Evidence: `.omo/evidence/task-2-fix-mcp-session-lifecycle.txt` - Commit: Y | feat(mcp): implement get_or_create_session and update_session_snapshot - -- [x] 3. Implement `add_acp_transport()` on MCPManager - What to do / Must NOT do: Add `add_acp_transport(self, session_id: str, client_id: str, transport: ClientTransport, connection_id: str, session_key: int) -> None` — gets session context via `get_or_create_session(session_id)`, adds transport to `ctx.connection_pool` (check SessionConnectionPool's API for adding transports — it uses `(client_id, skill_name)` keys), appends `(connection_id, session_key)` to `ctx.acp_connection_ids`. Must NOT create duplicate entries if called twice with same args. - Parallelization: Wave 1 | Blocked by: T1 | Blocks: T5, T8 - References: `src/agentpool/mcp_server/manager.py:115` (class), `session_pool.py` (SessionConnectionPool — check how transports are stored, keyed by `(client_id, skill_name)`), `src/acp/client/protocol.py` (ClientTransport type) - Acceptance criteria: `uv run pytest tests/mcp_server/test_session_lifecycle.py -k "test_add_acp_transport" -v` passes (test added in T5). `uv run ruff check src/agentpool/mcp_server/manager.py` passes. - QA scenarios: (happy) Add transport, verify it appears in `ctx.connection_pool` and `ctx.acp_connection_ids`. (failure) Add transport to non-existent session, verify session context is created. Evidence: `.omo/evidence/task-3-fix-mcp-session-lifecycle.txt` - Commit: Y | feat(mcp): implement add_acp_transport for session-scoped ACP tracking - -- [x] 4. Implement `cleanup_session()` on MCPManager with per-session lock - What to do / Must NOT do: Add `async cleanup_session(self, session_id: str) -> None`. Acquire `ctx._cleanup_lock` (from `get_or_create_session`). In try block: (1) clear `ctx.toolset_cache` dict, (2) call `await ctx.connection_pool.cleanup()` (with try/except to log but not re-raise), (3) delegate to ACP cleanup — if `self._acp_mcp_manager` is not None, call `await self._acp_mcp_manager.cleanup_session(session_id)` (with try/except to log). In finally block: always `self._session_contexts.pop(session_id, None)`. Must NOT re-raise exceptions from intermediate steps. Must NOT skip the pop in finally. D8: the lock makes concurrent calls idempotent — second caller blocks on lock, then finds session already popped. - Parallelization: Wave 1 | Blocked by: T1, T2 | Blocks: T15, T17, T19, T22 - References: `src/agentpool/mcp_server/manager.py:115` (class), `manager.py:275` (disconnect_all — pattern for clearing toolset cache), `manager.py:438` (cleanup — pattern for exit_stack closing), `session_pool.py` (SessionConnectionPool.cleanup(timeout=5.0) method), `src/agentpool_server/acp_server/acp_mcp_manager.py:253` (AcpMcpConnectionManager — will have cleanup_session() after T7). Note: MCPManager may need an `_acp_mcp_manager: AcpMcpConnectionManager | None = None` field to delegate ACP cleanup — check if it already has a reference, if not add one. - Acceptance criteria: `uv run pytest tests/mcp_server/test_session_lifecycle.py -k "test_cleanup_session" -v` passes (test added in T5). `uv run pytest tests/mcp_server/test_session_lifecycle.py -k "test_concurrent_cleanup" -v` passes. `uv run ruff check src/agentpool/mcp_server/manager.py` passes. - QA scenarios: (happy) Create session, add resources, call `cleanup_session()`, verify `_session_contexts` is empty. (failure) Call `cleanup_session()` twice concurrently (asyncio.gather), verify no errors and second call is no-op. Evidence: `.omo/evidence/task-4-fix-mcp-session-lifecycle.txt` - Commit: Y | feat(mcp): implement cleanup_session with per-session asyncio.Lock - -- [x] 5. Unit tests for MCPManager session context lifecycle - What to do / Must NOT do: Create `tests/mcp_server/test_session_lifecycle.py` with tests: (1) `test_get_or_create_session_creates_and_returns_same` — two calls return same object, (2) `test_get_or_create_session_creates_fresh_for_different_ids` — different session_ids get different contexts, (3) `test_update_session_snapshot_stores_snapshot` — snapshot is stored correctly, (4) `test_add_acp_transport_stores_transport_and_ids` — transport and (connection_id, session_key) are stored, (5) `test_cleanup_session_clears_all_resources` — after cleanup, `_session_contexts` is empty, (6) `test_cleanup_session_is_idempotent` — double-call is no-op, (7) `test_concurrent_cleanup_session_no_error` — asyncio.gather of two cleanup calls. Use `@pytest.mark.unit`. Use `pytest.fixture` for MCPManager instance. Must NOT use `getattr`/`hasattr` — use direct attribute access with type annotations. - Parallelization: Wave 1 | Blocked by: T1-T4 | Blocks: — - References: `tests/mcp_server/test_mcpmanager_caching.py` (existing test patterns), `tests/mcp_server/test_session_pool.py` (SessionConnectionPool test patterns), `tests/conftest.py` (fixtures, TestModel, observability disabled) - Acceptance criteria: `uv run pytest tests/mcp_server/test_session_lifecycle.py -v` — all 7 tests pass. `uv run pytest -m unit tests/mcp_server/test_session_lifecycle.py -v` — all pass with unit marker. - QA scenarios: (happy) All 7 tests pass. (failure) Intentionally break cleanup (remove pop from finally), verify test 6 and 7 fail. Evidence: `.omo/evidence/task-5-fix-mcp-session-lifecycle.txt` - Commit: Y | test(mcp): add session context lifecycle unit tests - -### Wave 2: P1c — AcpMcpConnectionManager Session Tracking (parallel with Wave 1) - -- [x] 6. Add `_session_connections` dict and `register_session_connection()` to AcpMcpConnectionManager - What to do / Must NOT do: Add `_session_connections: dict[str, set[tuple[str, int]]]` to `AcpMcpConnectionManager.__init__` (after `_connections` at line 259). Maps `session_id` → set of `(connection_id, session_key)` tuples. Add `register_session_connection(self, session_id: str, connection_id: str, session_key: int) -> None` — adds `(connection_id, session_key)` to the session's set, creating the set if missing. Must NOT modify `AcpMcpConnection._session_streams` (D5: reverse index at manager level, not changing int keys). - Parallelization: Wave 2 | Blocked by: — | Blocks: T7, T8, T15 - References: `src/agentpool_server/acp_server/acp_mcp_manager.py:253` (AcpMcpConnectionManager class), `acp_mcp_manager.py:259` (_connections dict), `acp_mcp_manager.py:34` (AcpMcpConnection class), `acp_mcp_manager.py:50` (_session_streams dict with int keys), `acp_mcp_manager.py:52` (_next_session_key int), `acp_mcp_manager.py:78` (register_session returns SessionStreamPair) - Acceptance criteria: `uv run pytest tests/agentpool_server/acp_server/test_acp_mcp_session_cleanup.py -k "test_register_session_connection" -v` passes (test added in T9). `uv run ruff check src/agentpool_server/acp_server/acp_mcp_manager.py` passes. - QA scenarios: (happy) Register a connection, verify it appears in `_session_connections`. (failure) Register same tuple twice, verify set deduplicates. Evidence: `.omo/evidence/task-6-fix-mcp-session-lifecycle.txt` - Commit: Y | feat(acp): add session connection tracking to AcpMcpConnectionManager - -- [x] 7. Implement `cleanup_session()` and `has_active_sessions()` on AcpMcpConnectionManager/AcpMcpConnection - What to do / Must NOT do: Add `has_active_sessions(self) -> bool` to `AcpMcpConnection` (line 34) — returns `len(self._session_streams) > 0`. **Metis GAP-1 resolution**: Modify `register_session()` at `acp_mcp_manager.py:78` to return `tuple[SessionStreamPair, int]` instead of just `SessionStreamPair` — return `(pair, key)` where `key` is the internal `_next_session_key`. Add `async cleanup_session(self, session_id: str) -> None` to `AcpMcpConnectionManager` (line 253) — **Metis GAP-12 resolution**: acquire `_cleanup_lock` (new `asyncio.Lock` added to `__init__`) before proceeding. Pop `session_id` from `_session_connections`, for each `(connection_id, session_key)` tuple: look up `AcpMcpConnection` via `self._connections[connection_id]`, look up `SessionStreamPair` via `conn._session_streams[session_key]` (note: _session_streams uses int keys, session_key is int from the modified `register_session()`), call `conn.unregister_session(pair)`, after processing all tuples for a connection check `conn.has_active_sessions()` — if False, optionally remove the connection (check existing `remove_connection()` logic at line ~290 for cleanup pattern). Must NOT change `_session_streams` key type from int to str (D5). - Parallelization: Wave 2 | Blocked by: T6 | Blocks: T15, T17, T19, T22 - References: `src/agentpool_server/acp_server/acp_mcp_manager.py:253` (class), `acp_mcp_manager.py:67` (close method), `acp_mcp_manager.py:78` (register_session), `acp_mcp_manager.py:98` (unregister_session takes SessionStreamPair), `acp_mcp_manager.py:50` (_session_streams dict), `acp_mcp_manager.py:224` (broadcast_to_sessions — pattern for iterating sessions) - Acceptance criteria: `uv run pytest tests/agentpool_server/acp_server/test_acp_mcp_session_cleanup.py -k "test_cleanup_session" -v` passes (test added in T9). `uv run ruff check src/agentpool_server/acp_server/acp_mcp_manager.py` passes. - QA scenarios: (happy) Register 2 sessions on same connection, cleanup one, verify connection still has 1 active session. (failure) Cleanup all sessions, verify connection is removed or has `has_active_sessions() == False`. Evidence: `.omo/evidence/task-7-fix-mcp-session-lifecycle.txt` - Commit: Y | feat(acp): implement cleanup_session and has_active_sessions - -- [x] 8. Wire `register_session_connection()` into `connect_acp_mcp_server()` - What to do / Must NOT do: **Metis GAP-5 resolution**: Change `connect_acp_mcp_server()` signature at `acp_agent.py:823` from `connect_acp_mcp_server(self, server: AcpMcpServer) -> str` to `connect_acp_mcp_server(self, server: AcpMcpServer, session_id: str) -> str`. Update call site at `session.py:478` to pass `self.session_id`. After calling `AcpMcpConnection.register_session()` (which now returns `tuple[SessionStreamPair, int]` per T7), extract the `session_key` (int) from the return. Call `self._mcp_manager.register_session_connection(session_id, connection_id, session_key)` and `agent.mcp.add_acp_transport(session_id, client_id, transport, connection_id, session_key)` (or equivalent MCPManager method from T3). Must NOT change the `SessionStreamPair` return type. - Parallelization: Wave 2 | Blocked by: T3, T6 | Blocks: T15 - References: `src/agentpool_server/acp_server/acp_agent.py:823` (connect_acp_mcp_server), `acp_agent.py:846` (disconnect_acp_mcp_server), `acp_agent.py:238` (_mcp_manager field), `acp_agent.py:263` (_mcp_manager init), `acp_mcp_manager.py:78` (register_session returns SessionStreamPair — check if key is stored on the pair or accessible), `src/agentpool_server/acp_server/session.py:165` (self.agent is BaseAgent) - Acceptance criteria: `uv run pytest tests/agentpool_server/acp_server/test_acp_mcp_session_cleanup.py -k "test_connect_registers_session" -v` passes (test added in T9). `uv run ruff check src/agentpool_server/acp_server/acp_agent.py` passes. - QA scenarios: (happy) Connect ACP MCP server, verify `register_session_connection()` was called with correct session_id and connection_id. (failure) Connect without session_id, verify graceful handling (no crash). Evidence: `.omo/evidence/task-8-fix-mcp-session-lifecycle.txt` - Commit: Y | feat(acp): wire register_session_connection into connect_acp_mcp_server - -- [x] 9. Unit tests for AcpMcpConnectionManager session connection tracking - What to do / Must NOT do: Create `tests/agentpool_server/acp_server/test_acp_mcp_session_cleanup.py` with tests: (1) `test_register_session_connection_adds_to_set`, (2) `test_register_deduplicates_same_tuple`, (3) `test_cleanup_session_unregisters_streams`, (4) `test_cleanup_preserves_shared_connection`, (5) `test_cleanup_removes_connection_with_no_sessions`, (6) `test_has_active_sessions_true_when_streams_exist`, (7) `test_has_active_sessions_false_when_empty`, (8) `test_connect_acp_mcp_server_registers_session` (integration with T8). Use `@pytest.mark.unit` for 1-7, `@pytest.mark.integration` for 8. Must NOT use `getattr`/`hasattr`. - Parallelization: Wave 2 | Blocked by: T6, T7, T8 | Blocks: — - References: `tests/agentpool_server/acp_server/test_acp_mcp_manager.py` (existing test patterns), `tests/agentpool_server/acp_server/test_acp_mcp_agent_integration.py` (integration test patterns) - Acceptance criteria: `uv run pytest tests/agentpool_server/acp_server/test_acp_mcp_session_cleanup.py -v` — all 8 tests pass. `uv run pytest -m unit tests/agentpool_server/acp_server/test_acp_mcp_session_cleanup.py -v` — 7 unit tests pass. - QA scenarios: (happy) All 8 tests pass. (failure) Remove `has_active_sessions()` check from cleanup, verify test 4 (shared connection preservation) fails. Evidence: `.omo/evidence/task-9-fix-mcp-session-lifecycle.txt` - Commit: Y | test(acp): add session connection cleanup unit tests - -### Wave 3: P1b — as_capability Session-Aware API (depends on Wave 1) - -- [x] 10. Change `as_capability()` signature and modify `_make_capability()` and `_process_snapshot()` - What to do / Must NOT do: Change `as_capability(self, snapshot: McpConfigSnapshot | None = None, session_pool: SessionConnectionPool | None = None)` at `manager.py:301` to `as_capability(self, session_id: str | None = None) -> AggregatingCapability` (or whatever the return type is — check current signature). When `session_id` is provided: look up `_SessionContext` via `get_or_create_session(session_id)`, use its `snapshot`, `connection_pool`, and `toolset_cache`. **Metis GAP-11 resolution**: Wrap the `_session_contexts` lookup in try/except `KeyError` — if the session context was popped by concurrent `cleanup_session()`, fall back to global-only capabilities (log a warning). This avoids the race condition without lock contention. When `session_id` is None: process only global configs from `self.servers` (backward compat, use `_toolset_cache`). **Metis GAP-7 resolution**: Modify `_make_capability(self, server, transport)` at line 374 to accept `toolset_cache: dict[str, Any]` parameter (instead of accessing `self._toolset_cache` directly) — when processing global configs, pass `self._toolset_cache`; when processing session-scoped configs, pass `ctx.toolset_cache`. Modify `_process_snapshot(self, snap)` at line 396 to pass the correct `toolset_cache` for session-scoped vs global configs. Must NOT remove `_toolset_cache` (D3: retained for global configs). Must NOT break `session_id=None` backward compat path. - Parallelization: Wave 3 | Blocked by: T1, T2 | Blocks: T11, T12, T13, T14 - References: `src/agentpool/mcp_server/manager.py:301` (as_capability current signature), `manager.py:374` (_make_capability), `manager.py:396` (_process_snapshot), `manager.py:147` (_toolset_cache), `config_snapshot.py` (McpConfigSnapshot.global_configs and .session_scoped_configs properties), `session_pool.py` (SessionConnectionPool) - Acceptance criteria: `uv run pytest tests/mcp_server/test_manager_capability.py -v` — all existing 19 tests pass (may need updates in T13). `uv run ruff check src/agentpool/mcp_server/manager.py` passes. `uv run --no-group docs mypy src/agentpool/mcp_server/manager.py` passes. - QA scenarios: (happy) Call `as_capability(session_id="s1")` with a session context that has a snapshot, verify session-scoped configs use per-session cache. (failure) Call `as_capability(session_id=None)`, verify only global configs are processed. Evidence: `.omo/evidence/task-10-fix-mcp-session-lifecycle.txt` - Commit: Y | refactor(mcp): change as_capability to session_id-based API - -- [x] 11. Implement session-scoped vs global config routing in `as_capability()` - What to do / Must NOT do: Inside `as_capability(session_id)`: if `session_id` is not None and `ctx.snapshot` is not None, call `_process_snapshot(ctx.snapshot, cache=False, toolset_cache=ctx.toolset_cache, connection_pool=ctx.connection_pool)` for session-scoped configs and `_process_snapshot(ctx.snapshot, cache=True)` for global configs. If `session_id` is None, process `self.servers` global configs with `_toolset_cache` as before. Must NOT mix session-scoped toolsets into `_toolset_cache`. - Parallelization: Wave 3 | Blocked by: T10 | Blocks: T12 - References: `src/agentpool/mcp_server/manager.py:301` (as_capability), `manager.py:396` (_process_snapshot), `config_snapshot.py` (global_configs property returns pool+agent configs, session_scoped_configs returns session+skill configs) - Acceptance criteria: `uv run pytest tests/mcp_server/test_manager_capability.py -k "session" -v` passes (tests updated in T13). `uv run ruff check src/agentpool/mcp_server/manager.py` passes. - QA scenarios: (happy) Session-scoped config produces toolset in `ctx.toolset_cache`, NOT in `_toolset_cache`. (failure) Global config produces toolset in `_toolset_cache`, NOT in `ctx.toolset_cache`. Evidence: `.omo/evidence/task-11-fix-mcp-session-lifecycle.txt` - Commit: Y | feat(mcp): route session-scoped configs to per-session cache - -- [x] 12. Update `get_agentlet()` call site in `agent.py` - What to do / Must NOT do: At `agent.py:901-903`, change `mcp_capabilities = await self.mcp.as_capability(snapshot=self._mcp_snapshot, session_pool=self._session_connection_pool)` to `mcp_capabilities = await self.mcp.as_capability(session_id=run_ctx.session_id if run_ctx else None)`. **Metis GAP-4 resolution**: Use `run_ctx.session_id` (already available via `AgentRunContext` parameter in `get_agentlet()`) instead of storing `self._session_id` on Agent. This avoids duplicating state. If `run_ctx` is None or `run_ctx.session_id` is None, pass `session_id=None` (global-only capabilities). Remove the direct setting of `self._mcp_snapshot` and `self._session_connection_pool` on the agent if they are now managed through `MCPManager.get_or_create_session()` and `update_session_snapshot()`. However, keep `self._mcp_snapshot` and `self._session_connection_pool` fields for backward compat if other code reads them — check all references. Must NOT remove `_mcp_snapshot` or `_session_connection_pool` field declarations if other code references them. - Parallelization: Wave 3 | Blocked by: T10, T11 | Blocks: T15, T16 - References: `src/agentpool/agents/native_agent/agent.py:901-903` (as_capability call), `agent.py:333-334` (_mcp_snapshot and _session_connection_pool declarations), `src/agentpool/orchestrator/session_controller.py:504-505` (child agent sets _mcp_snapshot and _session_connection_pool), `session_controller.py:586-587` (main agent sets _mcp_snapshot and _session_connection_pool) - Acceptance criteria: `uv run pytest tests/agentpool_server/acp_server/ -v` — existing ACP tests pass. `uv run ruff check src/agentpool/agents/native_agent/agent.py` passes. `uv run --no-group docs mypy src/agentpool/agents/native_agent/agent.py` passes. - QA scenarios: (happy) Agentlet creation calls `as_capability(session_id=...)` with correct session_id. (failure) Call `as_capability(session_id=None)`, verify it returns global-only capabilities. Evidence: `.omo/evidence/task-12-fix-mcp-session-lifecycle.txt` - Commit: Y | refactor(agent): update get_agentlet to use as_capability(session_id) - -- [x] 13. Update existing tests in `test_mcpmanager_caching.py` - What to do / Must NOT do: Update all 6 tests in `tests/mcp_server/test_mcpmanager_caching.py` to use new `as_capability(session_id=...)` API instead of `as_capability(snapshot=..., session_pool=...)`. Tests: toolset cache sharing, client_id keying, aggregating provider, no dedup hack, engineer/librarian scoping. For tests that verify cache sharing behavior, update to test per-session cache isolation instead. Must NOT delete tests — update them to verify the new behavior. - Parallelization: Wave 3 | Blocked by: T10 | Blocks: — - References: `tests/mcp_server/test_mcpmanager_caching.py` (6 existing tests), `tests/mcp_server/test_manager_capability.py` (19 existing tests — may also need updates) - Acceptance criteria: `uv run pytest tests/mcp_server/test_mcpmanager_caching.py -v` — all 6 updated tests pass. `uv run pytest tests/mcp_server/test_manager_capability.py -v` — all 19 tests pass. - QA scenarios: (happy) All tests pass with new API. (failure) Revert API change, verify tests fail with old signature. Evidence: `.omo/evidence/task-13-fix-mcp-session-lifecycle.txt` - Commit: Y | test(mcp): update caching tests for session_id API - -- [x] 14. Flip `test_stale_mcp_connection.py` tests from bug-documenting to fix-verifying - What to do / Must NOT do: Update all 5 tests in `tests/mcp_server/test_stale_mcp_connection.py`: (1) `test_session_resume_returns_stale_toolset_from_cache` → rename to `test_session_resume_returns_fresh_toolset` and assert session 2 gets a DIFFERENT toolset object, (2) `test_acp_client_id_is_deterministic` → keep as-is (still valid), (3) `test_session_pool_provides_fresh_transport` → keep as-is (still valid), (4) `test_multiple_acp_servers_all_go_stale` → rename to `test_multiple_acp_servers_get_fresh_toolsets` and assert freshness, (5) `test_disconnect_all_clears_cache_but_not_called_on_resume` → rename to `test_cleanup_session_clears_per_session_cache` and verify cleanup works. Add `try/finally` or `@pytest.fixture` teardown for resource cleanup — current tests skip cleanup on assertion failure. Must NOT keep assertions that verify the bug exists. - Parallelization: Wave 3 | Blocked by: T10 | Blocks: — - References: `tests/mcp_server/test_stale_mcp_connection.py` (5 existing tests documenting the bug) - Acceptance criteria: `uv run pytest tests/mcp_server/test_stale_mcp_connection.py -v` — all 5 updated tests pass. Tests verify the FIX, not the bug. - QA scenarios: (happy) Session 2 gets fresh toolset after session 1 is cleaned up. (failure) Remove cleanup_session call, verify test 1 and 4 fail (stale toolset returned). Evidence: `.omo/evidence/task-14-fix-mcp-session-lifecycle.txt` - Commit: Y | test(mcp): flip stale connection tests to verify fix - -### Wave 4: P1d — Wire cleanup_session into Close Paths (depends on Waves 1+2) - -- [x] 15. Wire `cleanup_session()` into `ACPSession.close()` and `SessionController._close_session_run_turn()` - What to do / Must NOT do: (1) In `session.py:795-823` (`ACPSession.close()`), add `await self.agent.mcp.cleanup_session(self.session_id)` BEFORE existing env/signal/prompt cleanup (before `acp_env.__aexit__()`). Check that `self.agent` has `.mcp` attribute and `.session_id` is accessible — `self.session_id` should be on ACPSession (check `session.py` for the field name, it may be `self._session_id` or similar). (2) In `session_controller.py:835-949` (`_close_session_run_turn()`), add `await agent.mcp.cleanup_session(session_id)` BEFORE `agent.__aexit__()` call (before line 941). Must NOT call cleanup_session AFTER `agent.__aexit__()` (agent context may be torn down). Must NOT skip cleanup if `is_per_session_agent=False` — the shared MCPManager still has session-scoped contexts that need cleanup. - Parallelization: Wave 4 | Blocked by: T4, T7, T8, T12 | Blocks: T17, T18, T19, T20, T22 - References: `src/agentpool_server/acp_server/session.py:795-823` (ACPSession.close), `session.py:165` (self.agent is BaseAgent), `src/agentpool/orchestrator/session_controller.py:835-949` (_close_session_run_turn), `session_controller.py:941-947` (agent.__aexit__ call with is_per_session_agent check) - Acceptance criteria: `uv run pytest tests/mcp_server/test_session_lifecycle.py -v` passes. `uv run pytest tests/agentpool_server/acp_server/ -v` — existing tests pass. `uv run ruff check src/agentpool_server/acp_server/session.py src/agentpool/orchestrator/session_controller.py` passes. - QA scenarios: (happy) Close session, verify `cleanup_session()` was called and `_session_contexts` is empty. (failure) Close session with active run, verify RunHandle is cancelled with timeout before cleanup. Evidence: `.omo/evidence/task-15-fix-mcp-session-lifecycle.txt` - Commit: Y | feat(session): wire cleanup_session into ACPSession.close and SessionController - -- [x] 16. Wire `get_or_create_session()` in `get_or_create_session_agent()` and update MCP snapshot setup - What to do / Must NOT do: **Metis GAP-4 resolution**: Do NOT add `self._session_id` to Agent — `get_agentlet()` uses `run_ctx.session_id` instead (see T12). (1) In `session_controller.py:397-672` (`get_or_create_session_agent()`), when creating a new agent: call `agent.mcp.get_or_create_session(session_id)` to create the session context, and call `agent.mcp.update_session_snapshot(session_id, snapshot)` if a snapshot is available (replace the direct `agent._mcp_snapshot = ...` and `agent._session_connection_pool = ...` setting at lines 504-505 and 586-587). **Metis GAP-6 resolution**: This must be done on ALL 3 agent creation paths: (a) child session (line 444-546), (b) main native (line 548-601), (c) non-native (line 603-657). For child sessions, the MCPManager is the parent's/pool's shared one — calling `get_or_create_session` on it is correct (session_ids are unique). Must NOT remove the `_mcp_snapshot` and `_session_connection_pool` field declarations if other code reads them — but do redirect the setting through MCPManager. - Parallelization: Wave 4 | Blocked by: T2, T12 | Blocks: T17, T18 - References: `src/agentpool/agents/native_agent/agent.py:333-334` (field declarations), `src/agentpool/orchestrator/session_controller.py:397-672` (get_or_create_session_agent), `session_controller.py:504-505` (child agent MCP setup), `session_controller.py:586-587` (main agent MCP setup) - Acceptance criteria: `uv run pytest tests/agentpool_server/acp_server/ -v` — existing tests pass. `uv run ruff check src/agentpool/agents/native_agent/agent.py src/agentpool/orchestrator/session_controller.py` passes. `uv run --no-group docs mypy src/agentpool/agents/native_agent/agent.py` passes. - QA scenarios: (happy) Create session agent, verify `_session_id` is set and `_session_contexts` has the session. (failure) Create agent without session_id, verify `as_capability(session_id=None)` works. Evidence: `.omo/evidence/task-16-fix-mcp-session-lifecycle.txt` - Commit: Y | feat(agent): add _session_id and wire get_or_create_session in SessionController - -- [x] 17. Integration test: create session → run turn → close → verify empty contexts - What to do / Must NOT do: Create integration test in `tests/mcp_server/test_session_lifecycle.py` (or a new `tests/integration/test_session_cleanup.py`): create an AgentPool with a native agent that has MCP servers, create a session, run a turn (use TestModel), close the session, verify `agent.mcp._session_contexts` is empty and `agent.mcp._toolset_cache` has no session-scoped entries. Use `@pytest.mark.integration`. Must NOT use real model calls — use TestModel from pydantic-ai. - Parallelization: Wave 4 | Blocked by: T15, T16 | Blocks: — - References: `tests/conftest.py` (TestModel setup, observability disabled), `tests/mcp_server/test_mcp_provider_lifecycle.py` (integration test patterns) - Acceptance criteria: `uv run pytest tests/mcp_server/test_session_lifecycle.py -k "test_integration_create_run_close" -v` passes. - QA scenarios: (happy) After close, `_session_contexts` is empty. (failure) Remove cleanup call from close path, verify test fails (context still present). Evidence: `.omo/evidence/task-17-fix-mcp-session-lifecycle.txt` - Commit: Y | test(mcp): integration test for session create→run→close lifecycle - -- [x] 18. Integration test: close → recreate same ID → verify fresh MCP resources - What to do / Must NOT do: Create integration test: create session "s1", run turn, close session, create new session "s1" (same ID), verify the new session has fresh MCP resources (different toolset objects, fresh connection pool). Use `@pytest.mark.integration`. Must NOT reuse the old session object. - Parallelization: Wave 4 | Blocked by: T15, T16 | Blocks: — - References: Same as T17 - Acceptance criteria: `uv run pytest tests/mcp_server/test_session_lifecycle.py -k "test_integration_close_recreate_fresh" -v` passes. - QA scenarios: (happy) New session "s1" has fresh resources, different from old session "s1". (failure) Remove cleanup, verify old resources leak into new session. Evidence: `.omo/evidence/task-18-fix-mcp-session-lifecycle.txt` - Commit: Y | test(mcp): integration test for session close→recreate freshness - -- [x] 19. Test: concurrent `cleanup_session()` calls (WebSocket disconnect + SessionController) - What to do / Must NOT do: Create test that simulates concurrent cleanup: spawn `asyncio.gather(agent.mcp.cleanup_session("s1"), agent.mcp.cleanup_session("s1"))`. Verify no errors, no double-cleanup, `_session_contexts` is empty. Use `@pytest.mark.unit`. Must NOT use real WebSocket connections — mock the disconnect trigger. - Parallelization: Wave 4 | Blocked by: T4, T15 | Blocks: T22 - References: `tests/mcp_server/test_session_lifecycle.py` (existing test patterns from T5) - Acceptance criteria: `uv run pytest tests/mcp_server/test_session_lifecycle.py -k "test_concurrent_cleanup_from_two_paths" -v` passes. - QA scenarios: (happy) Both calls complete without error, only one does actual cleanup. (failure) Remove lock from cleanup_session, verify race condition or double-cleanup error. Evidence: `.omo/evidence/task-19-fix-mcp-session-lifecycle.txt` - Commit: Y | test(mcp): concurrent cleanup_session from WebSocket and SessionController - -### Wave 5: P1e — Fix resume_session Early-Return (depends on Wave 4) - -- [x] 20. Remove early-return and implement close-then-recreate in `resume_session()` - What to do / Must NOT do: In `session_manager.py:243-249`, remove the early-return that returns stale session when `session_id in self._acp_sessions`. Replace with: (1) if session exists, call `SessionController.close_session(session_id)` first (handles RunHandle lifecycle with 10s timeout + cancel, calls `agent.mcp.cleanup_session()` via T15, calls `agent.__aexit__()`), (2) then call `ACPSession.close()` for ACP-specific cleanup (acp_env, signals, prompts — also calls `cleanup_session()` via T15, but idempotent via D8 lock), (3) remove from `_acp_sessions`, (4) proceed to create fresh session. Fallback: if `SessionController` is unavailable (tests), call `ACPSession.close()` only. Must NOT skip the `SessionController.close_session()` call when it's available — it handles active runs. Must NOT skip `ACPSession.close()` — it handles ACP-specific state. - Parallelization: Wave 5 | Blocked by: T15, T16 | Blocks: T21, T22, T23 - References: `src/agentpool_server/acp_server/session_manager.py:243-249` (early-return to remove), `session_manager.py:45` (_acp_sessions dict), `session_manager.py:371-391` (close_all_sessions pattern), `src/agentpool/orchestrator/session_controller.py:951-966` (close_session one-liner) - Acceptance criteria: `uv run pytest tests/agentpool_server/acp_server/ -v` — existing tests pass. `uv run ruff check src/agentpool_server/acp_server/session_manager.py` passes. `uv run --no-group docs mypy src/agentpool_server/acp_server/session_manager.py` passes. - QA scenarios: (happy) Resume existing session, verify old session is closed and new session has fresh resources. (failure) Resume with active run, verify RunHandle is cancelled with timeout. Evidence: `.omo/evidence/task-20-fix-mcp-session-lifecycle.txt` - Commit: Y | fix(acp): resume_session close-then-recreate instead of early-return - -- [x] 21. Test: resume → verify old session closed → fresh MCP resources - What to do / Must NOT do: Create test: create session, run turn, resume same session, verify old session was closed (check `_acp_sessions` had old entry removed and re-added), verify new session has fresh MCP resources (different toolset objects). Use `@pytest.mark.integration`. - Parallelization: Wave 5 | Blocked by: T20 | Blocks: — - References: `tests/agentpool_server/acp_server/test_acp_mcp_agent_integration.py` (integration test patterns) - Acceptance criteria: `uv run pytest tests/agentpool_server/acp_server/ -k "test_resume_closes_old_session" -v` passes. - QA scenarios: (happy) Resumed session has fresh MCP resources. (failure) Revert early-return, verify test fails (stale resources). Evidence: `.omo/evidence/task-21-fix-mcp-session-lifecycle.txt` - Commit: Y | test(acp): resume_session closes old and creates fresh - -- [x] 22. Test: resume after WebSocket reconnect → fresh ACP connections - What to do / Must NOT do: Create test: create session with ACP MCP server, simulate WebSocket disconnect, reconnect, resume session, verify fresh ACP connections are created and no stale connection references remain. Use `@pytest.mark.integration`. Must NOT use real WebSocket — mock the connection/disconnect. - Parallelization: Wave 5 | Blocked by: T15, T19, T20 | Blocks: — - References: `tests/agentpool_server/acp_server/test_acp_mcp_agent_integration.py` - Acceptance criteria: `uv run pytest tests/agentpool_server/acp_server/ -k "test_resume_after_reconnect" -v` passes. - QA scenarios: (happy) After reconnect+resume, ACP connections are fresh. (failure) Don't close old session on resume, verify stale connections persist. Evidence: `.omo/evidence/task-22-fix-mcp-session-lifecycle.txt` - Commit: Y | test(acp): resume after WebSocket reconnect creates fresh connections - -- [x] 23. Test: resume with active run → RunHandle cancelled with timeout - What to do / Must NOT do: Create test: create session, start a long-running turn, resume same session while run is active, verify RunHandle is cancelled with timeout before cleanup proceeds. Use `@pytest.mark.integration`. Must NOT block forever — use `asyncio.wait_for` in test with 30s timeout. - Parallelization: Wave 5 | Blocked by: T20 | Blocks: — - References: `src/agentpool/orchestrator/session_controller.py:835-949` (_close_session_run_turn with 10s timeout) - Acceptance criteria: `uv run pytest tests/agentpool_server/acp_server/ -k "test_resume_with_active_run" -v` passes. - QA scenarios: (happy) RunHandle cancelled, cleanup proceeds, new session created. (failure) Remove timeout from close_session, verify test hangs (would need timeout). Evidence: `.omo/evidence/task-23-fix-mcp-session-lifecycle.txt` - Commit: Y | test(acp): resume with active run cancels RunHandle - -### Wave 6: P1f — WebSocket Disconnect Hook (depends on Waves 4+2) - -- [x] 24. Add `on_disconnect` parameter to `_handle_websocket_client()` and call in `ConnectionClosed` handler - What to do / Must NOT do: Add `on_disconnect: Callable[[AgentSideConnection], Awaitable[None]] | None = None` parameter to `_handle_websocket_client()` at `transports.py:355`. **Metis GAP-3 resolution**: Generate a UUID4 string for each WebSocket connection at accept time, store it as `conn.connection_id: str` attribute on the `AgentSideConnection` instance (set right after creation at line 376). In the `ConnectionClosed` exception handler (line 412), call `await on_disconnect(conn)` BEFORE `conn.close()` in the finally block (line 414). The callback reads `conn.connection_id` to look up sessions. If `on_disconnect` is None, skip the call (backward compat). Must NOT make `on_disconnect` a required parameter. Must NOT call `on_disconnect` after `conn.close()`. - Parallelization: Wave 6 | Blocked by: T15 | Blocks: T25, T26, T27 - References: `src/acp/transports.py:355-428` (_handle_websocket_client), `transports.py:412` (ConnectionClosed catch), `transports.py:414-428` (finally block) - Acceptance criteria: `uv run pytest tests/ -k "websocket" -v` — existing WebSocket tests pass. `uv run ruff check src/acp/transports.py` passes. `uv run --no-group docs mypy src/acp/transports.py` passes. - QA scenarios: (happy) Disconnect triggers `on_disconnect` callback with connection object. (failure) `on_disconnect=None`, verify no callback called and existing behavior unchanged. Evidence: `.omo/evidence/task-24-fix-mcp-session-lifecycle.txt` - Commit: Y | feat(acp): add on_disconnect callback to websocket handler - -- [x] 25. Add `_connection_sessions` to ACPSessionManager and implement `close_all_sessions_for_connection()` - What to do / Must NOT do: (1) Add `_connection_sessions: dict[str, set[str]]` (connection_id → session_ids) to `ACPSessionManager.__init__` (after `_acp_sessions` at line 45). **Metis GAP-3 resolution**: The `connection_id` is the UUID4 string generated and stored on `AgentSideConnection.connection_id` (from T24). Populate `_connection_sessions` when sessions are created/resumed — add `session_id` to `_connection_sessions[connection_id]` set. The `connection_id` must be passed from the `Client` object or from the `AgentSideConnection` when creating the session. Check `ACPSessionManager.create_session()` to see how `client: Client` is received and how to access the underlying connection's `connection_id`. (2) Implement `async close_all_sessions_for_connection(self, connection_id: str) -> None` — iterates sessions for the connection. For each session: call `SessionController.close_session(session_id)` first (RunHandle lifecycle with timeout + cancel), then call `ACPSession.close()` for ACP-specific cleanup. Both must be called — SessionController handles RunHandle + agent lifecycle, ACPSession.close() handles ACP-specific state. Remove the connection entry from `_connection_sessions` after all sessions are closed. Must NOT skip SessionController.close_session() when available. Must NOT skip ACPSession.close(). - Parallelization: Wave 6 | Blocked by: T15, T24 | Blocks: T26, T28 - References: `src/agentpool_server/acp_server/session_manager.py:45` (_acp_sessions), `session_manager.py:371-391` (close_all_sessions pattern), `src/agentpool/orchestrator/session_controller.py:951-966` (close_session) - Acceptance criteria: `uv run pytest tests/agentpool_server/acp_server/ -k "close_all_sessions_for_connection" -v` passes. `uv run ruff check src/agentpool_server/acp_server/session_manager.py` passes. - QA scenarios: (happy) Disconnect connection, all sessions for that connection are closed. (failure) Disconnect, verify sessions on other connections are NOT affected. Evidence: `.omo/evidence/task-25-fix-mcp-session-lifecycle.txt` - Commit: Y | feat(acp): implement close_all_sessions_for_connection - -- [x] 26. Wire `on_disconnect` callback in server setup - What to do / Must NOT do: In the server setup that creates `_handle_websocket_client()` call (search for where `_handle_websocket_client` is called — likely in `ACPWebSocketTransport` or a server module), pass a callback that calls `ACPSessionManager.close_all_sessions_for_connection(connection_id)`. The callback needs access to the `ACPSessionManager` instance and the `connection_id` — check how the connection_id is determined at the call site. Must NOT create a circular dependency between transports.py and session_manager.py — use a callback, not a direct import. - Parallelization: Wave 6 | Blocked by: T24, T25 | Blocks: T27, T28 - References: Search for `_handle_websocket_client` call sites in `src/acp/` and `src/agentpool_server/acp_server/`. Check `src/acp/transports.py` for `ACPWebSocketTransport` class. - Acceptance criteria: `uv run pytest tests/agentpool_server/acp_server/ -v` — existing tests pass. `uv run ruff check src/` passes on changed files. - QA scenarios: (happy) WebSocket disconnect triggers `close_all_sessions_for_connection()`. (failure) Callback not wired, verify disconnect doesn't close sessions. Evidence: `.omo/evidence/task-26-fix-mcp-session-lifecycle.txt` - Commit: Y | feat(acp): wire on_disconnect to close_all_sessions_for_connection - -- [x] 27. Tests: WebSocket disconnect closes sessions + other connections unaffected - What to do / Must NOT do: Create tests: (1) `test_websocket_disconnect_closes_all_sessions` — create 2 sessions on same connection, disconnect, verify both closed via `cleanup_session()`, (2) `test_websocket_disconnect_preserves_other_connections` — create sessions on 2 connections, disconnect one, verify only that connection's sessions are closed. Use `@pytest.mark.integration`. Must NOT use real WebSocket — mock connection/disconnect. - Parallelization: Wave 6 | Blocked by: T25, T26 | Blocks: — - References: `tests/agentpool_server/acp_server/test_acp_mcp_agent_integration.py` - Acceptance criteria: `uv run pytest tests/agentpool_server/acp_server/ -k "websocket_disconnect" -v` — both tests pass. - QA scenarios: (happy) Disconnect closes all sessions for that connection. (failure) Don't wire callback, verify sessions remain open. Evidence: `.omo/evidence/task-27-fix-mcp-session-lifecycle.txt` - Commit: Y | test(acp): websocket disconnect closes sessions and preserves others - -- [x] 28. Test: WebSocket disconnect during active run → RunHandle cancelled with timeout - What to do / Must NOT do: Create test: create session, start long-running turn, simulate WebSocket disconnect, verify RunHandle is cancelled with timeout before cleanup proceeds. Use `@pytest.mark.integration`. Must NOT block forever — use `asyncio.wait_for` in test with 30s timeout. - Parallelization: Wave 6 | Blocked by: T25, T26 | Blocks: — - References: `src/agentpool/orchestrator/session_controller.py:835-949` (_close_session_run_turn with 10s timeout) - Acceptance criteria: `uv run pytest tests/agentpool_server/acp_server/ -k "websocket_disconnect_during_run" -v` passes. - QA scenarios: (happy) RunHandle cancelled, cleanup proceeds. (failure) Remove timeout, verify test hangs. Evidence: `.omo/evidence/task-28-fix-mcp-session-lifecycle.txt` - Commit: Y | test(acp): websocket disconnect during active run cancels RunHandle - -### Wave 7: End-to-End Verification - -- [x] 29. Run full test suite for MCP and ACP server - What to do / Must NOT do: Run `uv run pytest tests/mcp_server/ tests/agentpool_server/acp_server/ -v` and verify all tests pass. Capture full output. Must NOT mark any test as `xfail` or `skip` to make it pass. - Parallelization: Wave 7 | Blocked by: ALL | Blocks: — - References: All test files mentioned in previous todos - Acceptance criteria: `uv run pytest tests/mcp_server/ tests/agentpool_server/acp_server/ -v` — 0 failures, 0 errors. - QA scenarios: (happy) All tests pass. (failure) Any test fails — fix before proceeding. Evidence: `.omo/evidence/task-29-fix-mcp-session-lifecycle.txt` - Commit: N - -- [x] 30. Run unit test suite - What to do / Must NOT do: Run `uv run pytest -m unit` and verify all unit tests pass. Must NOT include slow or integration tests. - Parallelization: Wave 7 | Blocked by: ALL | Blocks: — - References: All test files - Acceptance criteria: `uv run pytest -m unit` — 0 failures, 0 errors. - QA scenarios: (happy) All unit tests pass. (failure) Any unit test fails — fix before proceeding. Evidence: `.omo/evidence/task-30-fix-mcp-session-lifecycle.txt` - Commit: N - -- [x] 31. Ruff lint check - What to do / Must NOT do: Run `uv run ruff check src/` and verify zero errors. Must NOT add `# noqa` comments to suppress errors. - Parallelization: Wave 7 | Blocked by: ALL | Blocks: — - References: All changed source files - Acceptance criteria: `uv run ruff check src/` — 0 errors. - QA scenarios: (happy) Zero lint errors. (failure) Any lint error — fix before proceeding. Evidence: `.omo/evidence/task-31-fix-mcp-session-lifecycle.txt` - Commit: N - -- [x] 32. Mypy type check - What to do / Must NOT do: Run `uv run --no-group docs mypy src/` and verify zero errors on changed files. Must NOT use `# type: ignore` to suppress errors (use proper type annotations). - Parallelization: Wave 7 | Blocked by: ALL | Blocks: — - References: All changed source files - Acceptance criteria: `uv run --no-group docs mypy src/` — 0 errors on changed files. - QA scenarios: (happy) Zero type errors. (failure) Any type error — fix before proceeding. Evidence: `.omo/evidence/task-32-fix-mcp-session-lifecycle.txt` - Commit: N - -- [x] 33. Automated end-to-end ACP test (replaces manual test per Metis GAP-15) - What to do / Must NOT do: Create automated integration test in `tests/agentpool_server/acp_server/test_e2e_session_lifecycle.py`: (1) Start ACP server in-process with a config that has MCP servers (use TestModel), (2) Create a mock WebSocket client that connects, (3) Create session, (4) Use MCP tool (mock), (5) Simulate WebSocket disconnect (close the mock connection), (6) Reconnect with new mock client, (7) Resume session, (8) Verify MCP tools work with fresh connections (assert toolset objects are different from pre-disconnect). Use `@pytest.mark.integration` and `@pytest.mark.slow`. Must NOT require a real ACP client or real model API key. - Parallelization: Wave 7 | Blocked by: ALL | Blocks: — - References: `agentpool serve-acp config.yml` command, example configs in `site/examples/*/config.yml` - Acceptance criteria: All 8 steps complete successfully. MCP tools work after reconnect+resume. - QA scenarios: (happy) Full flow works, MCP tools functional after resume. (failure) MCP tools fail after resume — indicates stale resources. Evidence: `.omo/evidence/task-33-fix-mcp-session-lifecycle.txt` - Commit: N - -## Final verification wave -> Runs in parallel after ALL todos. ALL must APPROVE. Surface results and wait for the user's explicit okay before declaring complete. -- [x] F1. Plan compliance audit — verify every task in `openspec/changes/fix-mcp-session-lifecycle/tasks.md` is implemented and checked off. Compare task-by-task. -- [x] F2. Code quality review — `uv run ruff check src/` and `uv run --no-group docs mypy src/` both pass with zero errors. Review changed code for `getattr`/`hasattr` usage (forbidden), missing type annotations, TODOs left in code. -- [x] F3. Real manual QA — run the manual ACP test from T33: connect → session → MCP tool → disconnect → reconnect → resume → verify MCP tools work. Capture output as evidence. -- [x] F4. Scope fidelity — verify NO changes to `MessageNode`, `AgentPool` registry, `MCPResourceProvider` model, or config API. Verify NO Phase 2 features were introduced. Verify all 5 stale-mcp tests are now fix-verifying (not bug-documenting). - -## Commit strategy -- One commit per todo that has `Commit: Y` (28 commits) -- Todos with `Commit: N` (T29-T33 verification) are verification-only, no commits -- Commit message format: `(): ` matching repo style -- Types: `feat`, `fix`, `refactor`, `test` -- Scopes: `mcp`, `acp`, `agent`, `session` -- Branch: `fix-mcp-session-lifecycle` (already created as worktree) - -## Success criteria -1. `uv run pytest tests/mcp_server/ tests/agentpool_server/acp_server/ -v` — 0 failures -2. `uv run pytest -m unit` — 0 failures -3. `uv run ruff check src/` — 0 errors -4. `uv run --no-group docs mypy src/` — 0 errors on changed files -5. Manual ACP test (T33) — MCP tools work after WebSocket disconnect + reconnect + resume -6. All 5 tests in `test_stale_mcp_connection.py` verify the fix (not the bug) -7. `_session_contexts` is empty after session close on all close paths (ACPSession.close, SessionController.close_session, WebSocket disconnect) -8. `resume_session()` creates fresh session, not returning stale one diff --git a/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/.openspec.yaml b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/.openspec.yaml new file mode 100644 index 000000000..aee4ef1e1 --- /dev/null +++ b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-07 diff --git a/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/design.md b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/design.md new file mode 100644 index 000000000..e60105160 --- /dev/null +++ b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/design.md @@ -0,0 +1,199 @@ +## Context + +AgentPool's ACP agent layer was built before the proxy chain concept existed. The current `ACPAgent` conflates subprocess management, ACP client communication, and event conversion into a single 872-line class. Three structural defects exist: + +1. **Dead ACPTurn**: `create_turn()` casts `ACPAgentAPI` to `ACPClientProtocol`, but the API doesn't implement the required `prompt()`, `stream_events()`, `get_messages()` methods. Runtime crash is avoided only because `run_stream()` bypasses Turn entirely via `_stream_events()` inline logic. +2. **Polling-based streaming**: `poll_acp_events()` uses a 50ms timeout loop to drain a deque instead of async push. +3. **Double conversion**: When agentpool is both ACP server and client (nested), events convert ACP→native→ACP (~1600 lines) when passthrough should be zero-copy. + +The proxy chain RFD (`agent-client-protocol/docs/rfds/proxy-chains.mdx`) defines a Conductor pattern that routes messages through a chain of proxies, each able to intercept and transform bidirectionally. This architecture directly solves the double conversion problem (proxies pass through untouched when no interception needed) and provides a clean extension model for hooks, context injection, and tool providers. + +**Current state of the wire protocol layer**: `Connection`, `AgentSideConnection`, `ClientSideConnection` are transport-agnostic and proxy-chain-ready. The `Agent` and `Client` protocols are extensible. `AcpMcpTransport` already implements MCP-over-ACP. `ACPBridge` demonstrates the forwarding pattern that the Conductor generalizes. + +## Goals / Non-Goals + +**Goals:** +- Implement the Conductor + Proxy protocol per the RFD (`proxy/initialize`, `proxy/successor`) +- Make ACPTurn the single execution path for ACP agents (fix via `ACPClientAdapter`) +- Eliminate 50ms polling — replace with async push streaming +- Eliminate double conversion in passthrough scenarios +- Reuse existing hook system (`CallableHook`, `CommandHook`, `PromptHook`) as `HookProxy` components +- Migrate `ToolManagerBridge` to `ResourceProvider` +- Make `ACPAgent` output `ChatMessage[str]` instead of raw `str` +- Support YAML `proxy_chain:` configuration + +**Non-Goals:** +- Implement the full ACP remote transport (Streamable HTTP/WS) — future work +- Implement session fork (`session/fork`) — separate RFD +- Ratify the proxy chain RFD — we implement against the current draft +- Refactor native (PydanticAI) agents to use proxy chains — they don't need wire-level interception +- Implement Conductor-in-proxy-mode for tree topologies — future work +- Backward compatibility for `_stream_events()` internal API — internal method, safe to change + +## Decisions + +### D1: Conductor as MessageNode + +**Decision**: `Conductor` inherits from `MessageNode[ChatMessage, ChatMessage[str]]`. + +**Rationale**: The Conductor must integrate with agentpool's graph/team system. As a `MessageNode`, it can be composed in teams, connected to other nodes, and participate in the graph-based execution model. The Conductor's `_step` property wraps the proxy chain execution as a pydantic-graph Step. + +**Alternative considered**: Conductor as a standalone class with `run()`/`run_stream()` methods mimicking `MessageNode` interface. Rejected — would require duplicating graph integration logic and break the unified `MessageNode` abstraction. + +### D2: ACPTurn as Single Execution Path + +**Decision**: Both `TurnRunner` (via `SessionPool.receive_request()`) and `run_stream()` use `ACPTurn.execute()` as the single turn execution path. The `_stream_events()` bypass is deleted. + +**Rationale**: The dual-path divergence (Path A: TurnRunner→create_turn→execute vs Path B: run_stream→_stream_events) is the root cause of ACPTurn being dead code. By making ACPTurn functional via `ACPClientAdapter`, both paths converge. This also means `TurnRunner` works with ACP agents (previously broken). + +**Alternative considered**: Keep dual paths, just fix ACPTurn for the TurnRunner path. Rejected — maintaining two execution paths for the same agent type is a maintenance burden and was the original cause of the divergence. + +### D3: ACPClientAdapter Design + +**Decision**: `ACPClientAdapter` wraps `ACPAgentAPI` to implement a **modified** `ACPClientProtocol`. The protocol is redefined to support non-blocking semantics: +- `prompt()`: Launches `api.prompt()` as a background task (fire-and-forget), returns `None` (not `PromptResponse`) +- `stream_events()`: Returns an `AsyncIterator[SessionUpdate]` (no `response` parameter) that yields items from an `asyncio.Queue` as `client_handler.session_update()` pushes them. When the background prompt task completes, the adapter signals stream completion. +- `stop_reason` property: Returns the `PromptResponse.stop_reason` after streaming completes (accessed internally when the background task finishes) +- `get_messages()`: Calls `api.get_messages()` after prompt completes + +**Rationale**: The original `ACPClientProtocol` required `prompt()` to return `PromptResponse` and `stream_events()` to take a `response` parameter. This assumes synchronous completion — `prompt()` blocks until all notifications arrive, then `stream_events(response)` iterates them. But `ACPAgentAPI.prompt()` is blocking and the adapter needs to invert this: return immediately from `prompt()`, stream events as they arrive, then expose `stop_reason` after completion. + +The protocol change is internal to this change's scope — `ACPClientProtocol` is only implemented by `ACPClientAdapter` and consumed by `ACPTurn`. The `PromptResponse` is stored internally by the adapter when the background task completes, and `stop_reason` is exposed as a read-only property. + +The async queue SHALL have a `max_buffer_size` of 1000 (matching the current `anyio.create_memory_object_stream` value) to prevent unbounded memory growth if the consumer is slower than the ACP server's notification rate. + +**Alternative considered**: Make `ACPAgentAPI` natively async with streaming. Rejected — would require deep changes to the ACP client library; the adapter is a localized bridge. + +**Alternative considered**: Return a future/placeholder `PromptResponse` from `prompt()` that resolves when the background task completes. Rejected — adds complexity for callers that would need to await the future; the `stop_reason` property is simpler. + +### D4: HookProxy Adapter Pattern + +**Decision**: `HookProxy` implements the `Proxy` protocol and wraps existing `Hook` instances. It handles **all 4 hook types** by mapping ACP wire messages to `HookInput` events: + +- `session/prompt` → `HookInput(event="pre_turn")` — hook can inject context (`additional_context`), deny (block prompt), or modify prompt before forwarding +- `session/update` with `ToolCallStart` → `HookInput(event="pre_tool_use")` — hook can modify tool input (`modified_input`) or deny (block tool call before it reaches terminal agent) +- `session/update` with `ToolCallComplete` → `HookInput(event="post_tool_use")` — hook can replace tool output (`modified_output`) +- JSON-RPC response to `session/prompt` request → `HookInput(event="post_turn")` — hook can modify agent response (`modified_output`). The proxy correlates the `session/prompt` request ID with its JSON-RPC response to determine turn completion (not individual `AgentMessageChunk` updates, which arrive throughout the turn). + +`HookResult.decision=="deny"` → proxy stops forwarding (blocking, not advisory). `HookResult.additional_context` → prepended to prompt. `HookResult.modified_input` → replaces tool input. `HookResult.modified_output` → replaces output. + +**Hook semantics are per-turn, not per-run-loop**: The `pre_turn`/`post_turn` names (renamed from `pre_run`/`post_run` by `unify-hook-system`) reflect the correct per-turn semantic. In a multi-turn `RunHandle` (with steer/followup), these fire for **each turn**, not just the first and last. HookProxy naturally implements this because `session/prompt` and `session/update` flow through the proxy chain on every turn. + +**Rationale**: The RFD says proxies subsume hooks. But rewriting all hook implementations would waste existing, tested code. The adapter pattern preserves the hook system while elevating it to wire-protocol level. Same hook can be used for both native agents (via `HookAwareTurn` in-process) and ACP agents (via `HookProxy` at wire level). HookProxy is strictly superior to in-process hooks for ACP agents because it intercepts messages **before** they reach the terminal agent subprocess — enabling true blocking, not just advisory warnings. + +**Alternative considered**: Replace hooks entirely with proxy implementations. Rejected — existing hooks (`CallableHook`, `CommandHook`, `PromptHook`) are tested and in use. Rewriting them as proxies would be a larger scope change with no benefit. + +### D5: Passthrough Optimization + +**Decision**: When a proxy has no interception logic for a given message type, it forwards the message without deserializing/reserializing. Proxies declare their intercepted message types during `proxy/initialize` — the response includes a `intercepted_methods` list (e.g., `["session/prompt", "session/update"]`). The Conductor tracks this registration and short-circuits the chain for message types no proxy intercepts. + +**Rationale**: This solves the double conversion problem. In the current architecture, nesting ACP server + client causes ~1600 lines of ACP→native→ACP conversion. With proxy chains, a passthrough proxy forwards the raw JSON-RPC message without parsing. Only proxies that explicitly register interest in a message type during initialization pay the deserialization cost. + +**Alternative considered**: Always deserialize and re-serialize. Rejected — defeats the purpose of proxy chains for passthrough scenarios. + +**Alternative considered**: Inspect every message at every proxy. Rejected — adds latency even when no interception is needed. + +### D6: Terminal Agent Detection by Chain Position + +**Decision**: Terminal agents implement the existing `acp.Agent` protocol (no changes). The Conductor determines which components are proxies vs terminal agent based on **chain position** from configuration — the last component in the chain is the terminal agent, all others are proxies. The Conductor sends `proxy/initialize` to all proxy components and `initialize` to the terminal agent (the last component). Terminal agents don't know about proxy chains — they just handle `session/prompt` and emit `session/update`. + +**Rationale**: This follows the RFD exactly. The RFD specifies: "The conductor MUST send `proxy/initialize` to all proxy components" and "The conductor MUST send `initialize` to the final agent component." The conductor decides which method to send based on chain position — it doesn't detect from responses. The spec's earlier framing of "checking response to initialization" was incorrect. + +**Alternative considered**: Create a `TerminalAgent` protocol. Rejected — the existing `Agent` protocol already defines the terminal agent interface. Adding a new protocol would be redundant. + +**Alternative considered**: Auto-detect by sending `proxy/initialize` first and falling back to `initialize`. Rejected — adds complexity and latency for no benefit when chain position is known from configuration. + +### D7: YAML Configuration + +**Decision**: New `proxy_chain:` section in agent config: + +```yaml +agents: + my_agent: + type: acp + command: goose + args: [acp] + proxy_chain: + - type: context_injection + agents_md: true + skills: [code-review, debugging] + - type: tool_provider + mcp_servers: [filesystem, git] + - type: hook + event: pre_tool_use + command: ./security-check.sh +``` + +When `proxy_chain` is omitted, the Conductor runs with zero proxies (direct conductor→agent). + +**Rationale**: Declarative configuration matches agentpool's YAML-first philosophy. Each proxy entry maps to a registered proxy implementation. The `type` field discriminates which proxy class to instantiate. + +**Alternative considered**: Programmatic configuration only. Rejected — agentpool is YAML-first; programmatic API can be added later if needed. + +### D8: EventBus and Conductor Coexistence + +**Decision**: EventBus handles framework-level events (`RichAgentStreamEvent`), Conductor handles ACP wire-level messages (JSON-RPC). ACPTurn is the bridge — it receives ACP messages from the Conductor and converts them to `RichAgentStreamEvent` for the EventBus. + +**Rationale**: EventBus is the existing event distribution system for protocol servers. Conductor operates at a different abstraction layer (wire protocol). Mixing them would conflate concerns. ACPTurn already does this conversion — it just needs to be functional (which D3 solves). + +### D9: HookProxy and HookAwareTurn Coexistence + +**Decision**: Two hook firing mechanisms coexist for ACP agents. The Conductor controls which mechanism is active by controlling whether hooks are passed to `ACPTurn`: + +| Mechanism | Scope | Firing Location | Capability | When Active | +|---|---|---|---|---| +| `HookAwareTurn` (v1) | All 4 hook types | `ACPTurn.execute()` (in-process) | Advisory (can't block subprocess) | No HookProxy in chain | +| `HookProxy` (v2) | All 4 hook types | Proxy chain (wire-level) | Blocking (intercepts before terminal agent) | HookProxy in chain | + +**Activation rules**: +- When Conductor has a `HookProxy` in the chain: Conductor passes `_hooks=None` to `ACPTurn`. `HookAwareTurn`'s guard (`if self._hooks is None: return None`) skips all hook firing. Hooks fire at wire-level via `HookProxy`. +- When no `HookProxy` in chain: Conductor passes the agent's `AgentHooks` to `ACPTurn`. `HookAwareTurn` fires all 4 hook types in-process (advisory for tool hooks). +- Conductor **auto-inserts** `HookProxy` at chain position 0 when agent has hooks configured and no explicit `HookProxy` in `proxy_chain`. + +**Why not use `hooks_fired` guard**: The `unify-hook-system` spec clears `hooks_fired` per-turn (to support multi-turn runs). If HookProxy set keys at chain init, they'd be cleared in turn 2+. Passing `_hooks=None` is simpler and doesn't interact with the per-turn clearing logic. The `hooks_fired` guard remains solely for the `_run_stream_once()` → `Turn.execute()` migration in `unify-hook-system`. + +**Migration path**: `unify-hook-system` implements `HookAwareTurn` (v1) first, including Section 11 "Future Work" which describes building the `ACPClientAPI` adapter. `acp-proxy-chain-refactor` Phase 1 implements that adapter (`ACPClientAdapter`), making `ACPTurn.execute()` the single ACP execution path. Phase 4 adds `HookProxy` (v2). Eventually, when all ACP agents use Conductor, `HookAwareTurn` on `ACPTurn` can be removed (kept only for native `NativeTurn`). + +**Rationale**: Both mechanisms serve the same hooks (`CallableHook`, `CommandHook`, `PromptHook`) — they differ only in WHERE interception happens (in-process vs wire). The `_hooks=None` approach is cleaner than `hooks_fired` because it doesn't require coordination with the per-turn clearing logic. + +**Alternative considered**: Use `hooks_fired` guard as originally proposed. Rejected — per-turn clearing in `unify-hook-system` would require HookProxy to re-set keys every turn, creating unnecessary coupling. Passing `_hooks=None` is a single assignment at Conductor construction time. + +## Risks / Trade-offs + +**[RFD not ratified]** → We implement against the current draft. If `proxy/initialize` or `proxy/successor` method names change, only the wire method names need updating — the Conductor's internal architecture is stable. Mitigation: isolate wire method names in a single constants module. + +**[No Python reference implementation]** → The RFD has a working Rust impl (`sacp-conductor`, `sacp-proxy`) but no Python reference. We're the first Python implementation. Mitigation: follow the RFD spec closely, use the Rust impl as reference for edge cases. + +**[Two unratified RFDs dependency]** → `ToolProviderProxy` (Phase 4) depends on MCP-over-ACP transport, which is itself a separate unratified RFD. Building on two unratified specs compounds the risk. Mitigation: defer `ToolProviderProxy` to a separate change if MCP-over-ACP RFD is not ratified by Phase 4 implementation time. Mark `ToolProviderProxy` as experimental. + +**[Large refactoring scope]** → ~4-5 weeks of work across 6 phases (updated from initial 2-3 week estimate after architecture review). Mitigation: phased delivery — Phase 1 (ACPTurn fix) is independently shippable and immediately useful. Each subsequent phase builds on the previous without breaking. + +**[Backward compatibility]** → `ACPAgent._stream_events()` signature changes. `create_turn()` behavior changes (previously crashed, now works). Mitigation: these are internal methods. The public `run()`/`run_stream()` API remains stable. Users who depended on `_stream_events()` behavior are depending on a workaround. + +**[HookProxy message mapping complexity]** → Mapping ACP wire messages to hook lifecycle events requires understanding both systems. The `post_turn` hook requires JSON-RPC request/response correlation (tracking `session/prompt` request IDs and matching them with responses). Mitigation: comprehensive tests for each message type → hook event mapping, including request/response correlation. + +**[Conductor subprocess management]** → Conductor now manages subprocess lifecycle instead of ACPAgent. If the Conductor crashes, subprocesses may orphan. Mitigation: Conductor uses task groups (anyio) for structured concurrency; subprocess cleanup runs in finally block. + +**[ACPSessionState deletion scope]** → `ACPSessionState` tracks more than the update deque — it holds `current_model_id`, `models`, `modes`, `config_options`, `available_commands`. Deleting the entire class (task 6.1) would break model switching, mode switching, and command population. Mitigation: Only delete the deque mechanism. Preserve model/mode/config state in a renamed `ACPState` dataclass or migrate to `ACPClientAdapter`. + +**[ACPClientHandler state update routing]** → The current `ACPClientHandler.session_update()` routes state updates (mode, model, config, commands) differently from stream data — it returns early for state updates and only queues stream data. The adapter design must preserve this bifurcation. Mitigation: Spec requires that `session_update()` continues to process state updates in-place and only pushes stream-data updates (text chunks, tool calls, thoughts) to the async queue. + +**[Unbounded queue in ACPClientAdapter]** → The async queue could grow unbounded if the consumer is slower than the ACP server's notification rate. Mitigation: The queue SHALL have a `max_buffer_size` of 1000 (matching the current `anyio.create_memory_object_stream` value). + +**[Proxy chain error propagation]** → If a proxy throws during `proxy/successor`, the conductor must decide how to handle it. Mitigation: Proxy exceptions produce a JSON-RPC error response forwarded back through the chain. The conductor does NOT silently skip failed proxies (a security hook proxy failing silently is dangerous). + +## Migration Plan + +1. **Phase 1** (shippable independently): Fix ACPTurn via ACPClientAdapter. Replace polling with async push. This alone fixes 2 of 3 critical issues. +2. **Phase 2-3**: Implement Conductor + Proxy protocol. Rewrite ACPAgent to use Conductor. Old ACPAgent code remains until new path is verified. +3. **Phase 4**: Add built-in proxy implementations (HookProxy, ContextInjectionProxy, ToolProviderProxy). +4. **Phase 5**: Refactor server-side `AgentPoolACPAgent` as terminal agent. +5. **Phase 6**: Delete dead code, legacy paths, migrate ToolManagerBridge → ResourceProvider. + +**Rollback**: Phases 1-3 can be feature-flagged via `use_conductor: true` in agent config. If issues arise, set `use_conductor: false` to fall back to the old `_stream_events()` path. Flag removed in Phase 6 after confidence is established. + +## Open Questions + +- **Proxy hot-swap (out of scope)**: Should the Conductor support hot-swapping proxies at runtime (add/remove proxy without restarting the chain)? This is explicitly **out of scope** for this change. The design should not preclude it, but it will not be implemented. Future work. +- **Concurrency: multiple concurrent prompts** (resolved): ACP sessions typically allow one active prompt at a time. If `adapter.prompt()` is called while a previous prompt is still streaming, the adapter SHALL raise a `RuntimeError("Prompt already in progress")`. This is a formal requirement in `acp-client-adapter/spec.md`. +- **Proxy chains in team composition**: How should proxy chains interact with the graph-based team execution? If a team member is an ACP agent with a proxy chain, does the chain execute within the Step's `call()` method? (Answer: yes — the Conductor's `_step` property handles this.) diff --git a/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/proposal.md b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/proposal.md new file mode 100644 index 000000000..9cc3601f4 --- /dev/null +++ b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/proposal.md @@ -0,0 +1,44 @@ +## Why + +The current ACPAgent implementation was built before the ACP proxy chain concept existed. It conflates subprocess management, ACP client communication, and event conversion into a single monolithic class with three critical issues: (1) `ACPTurn` — the designed Turn abstraction — is non-functional dead code due to a missing adapter, (2) streaming uses a 50ms polling loop instead of async push, and (3) nesting ACP server + client causes ~1600 lines of bidirectional event conversion (ACP→native→ACP) that should be zero-copy passthrough. The proxy chain RFD (`docs/rfds/proxy-chains.mdx` in agent-client-protocol) defines a conductor pattern that directly solves these structural problems. + +## What Changes + +- **NEW**: `Conductor` class — manages proxy chain lifecycle, routes `proxy/successor` messages, spawns subprocesses +- **NEW**: `Proxy` protocol (`typing.Protocol`) — defines `proxy_initialize()` + `proxy_successor()` per RFD +- **NEW**: `ProxySideConnection` — wire-protocol wrapper for proxy components (analogous to `AgentSideConnection`/`ClientSideConnection`) +- **NEW**: `ACPClientAdapter` — bridges `ACPAgentAPI` (blocking prompt + notification deque) to `ACPClientProtocol` (stream interface), making `ACPTurn` functional +- **NEW**: `HookProxy` — wraps existing `CallableHook`/`CommandHook`/`PromptHook` as proxy chain components, reusing the entire hook system +- **NEW**: Built-in proxy implementations: `ContextInjectionProxy`, `ToolProviderProxy` (reusing `AcpMcpTransport`), `PermissionHookProxy` +- **NEW**: YAML `proxy_chain:` configuration section for defining ordered proxy chains +- **REWRITE**: `ACPAgent` — split into Conductor (subprocess management) + ACPTurn (turn cycle). Delete `_stream_events()` inline logic, `poll_acp_events()`, `ACPSessionState` deque +- **REWRITE**: `ACPClientHandler.session_update()` — push directly to async stream (eliminate `TimeoutableEvent` polling) +- **FIX**: `ACPTurn` — remove `cast()` hack, use `ACPClientAdapter` for real `ACPClientProtocol` compliance +- **FIX**: `ACPAgent` output type `str` → `ChatMessage[str]` for `MessageNode` contract compliance +- **DELETE**: `poll_acp_events()` and 50ms timeout loop +- **DELETE**: Legacy `ACPSession.process_prompt()` dual path (consolidate to `ACPProtocolHandler`) +- **MIGRATE**: `ACPAgent` from `ToolManagerBridge` (deprecated) to `ResourceProvider` +- **BREAKING**: `ACPAgent.create_turn()` now returns a functional `ACPTurn` (previously would crash at runtime) +- **BREAKING**: `ACPAgent._stream_events()` signature changes — conductor-driven, no inline polling + +## Capabilities + +### New Capabilities +- `acp-proxy-chain`: Conductor pattern, proxy/initialize + proxy/successor protocol, proxy chain lifecycle management +- `acp-proxy-impls`: Built-in proxy implementations (context injection, tool provider, permission hooks) and HookProxy adapter for reusing existing hook system +- `acp-client-adapter`: ACPClientAdapter bridging ACPAgentAPI to ACPClientProtocol, making ACPTurn functional with async push streaming + +### Modified Capabilities +- `acp-server`: Server-side ACP agent (`AgentPoolACPAgent`) becomes terminal agent behind conductor; legacy `ACPSession.process_prompt()` dual path removed +- `acp-single-execution-path`: ACPTurn becomes the single execution path for ACP agents (eliminates path A/B divergence between TurnRunner and run_stream) +- `session-orchestration`: TurnRunner now works with ACP agents via functional ACPTurn (previously broken due to missing ACPClientProtocol implementation) + +## Impact + +- **`src/acp/`**: New `conductor.py`, `proxy/` package (protocol, connection, impls). Existing `Connection`, `AgentSideConnection`, `ClientSideConnection` unchanged (additive only). +- **`src/agentpool/agents/acp_agent/`**: Major rewrite of `acp_agent.py`, `client_handler.py`. New `adapter.py`. Delete `turn.py` dead code patterns (ACPTurn moves to use adapter). Simplify `acp_converters.py` (passthrough eliminates most conversion). +- **`src/agentpool_server/acp_server/`**: `AgentPoolACPAgent` refactored as terminal agent. `ACPProtocolHandler` unchanged (already works). `ACPEventConverter` becomes a proxy component. +- **`src/agentpool/hooks/`**: No changes to hook implementations. New `HookProxy` adapter in `src/acp/proxy/impls/` wraps them. +- **`src/agentpool/models/`**: New `ProxyChainConfig` model. `ACPAgentConfig` updated with optional `proxy_chain` field. +- **YAML configs**: New `proxy_chain:` section. Existing configs unchanged (backward compatible — no proxy_chain = direct conductor→agent). +- **Dependencies**: No new external dependencies. Reuses existing `anyio`, `pydantic`, `acp` library. diff --git a/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/acp-client-adapter/spec.md b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/acp-client-adapter/spec.md new file mode 100644 index 000000000..8ccf5166c --- /dev/null +++ b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/acp-client-adapter/spec.md @@ -0,0 +1,77 @@ +## ADDED Requirements + +### Requirement: ACPClientProtocol SHALL be redefined for non-blocking semantics + +The `ACPClientProtocol` interface SHALL be modified to support non-blocking prompt semantics. The `prompt()` method SHALL return `None` (not `PromptResponse`). The `stream_events()` method SHALL take no `response` parameter and SHALL return an `AsyncIterator[SessionUpdate]`. A `stop_reason` property SHALL be added to expose the `PromptResponse.stop_reason` after streaming completes. This is an internal interface change — `ACPClientProtocol` is only implemented by `ACPClientAdapter` and consumed by `ACPTurn`. + +#### Scenario: prompt returns None +- **WHEN** `ACPClientAdapter.prompt()` is called +- **THEN** the adapter SHALL launch `api.prompt()` as a background asyncio task (fire-and-forget) +- **AND** SHALL return `None` immediately without waiting for the prompt to complete +- **AND** SHALL NOT block the calling coroutine + +#### Scenario: stream_events takes no arguments +- **WHEN** `ACPClientAdapter.stream_events()` is called (with no arguments) +- **THEN** the adapter SHALL return an async iterator that yields ACP session update notifications +- **AND** notifications SHALL be pushed to the queue by `ACPClientHandler.session_update()` as they arrive +- **AND** the iterator SHALL yield notifications in order +- **AND** the iterator SHALL signal completion when the prompt background task completes + +#### Scenario: stop_reason available after streaming +- **WHEN** the prompt background task completes +- **THEN** the adapter SHALL store the `PromptResponse` internally +- **AND** the `stop_reason` property SHALL return the `PromptResponse.stop_reason` value +- **AND** accessing `stop_reason` before streaming completes SHALL raise `RuntimeError("stop_reason not available until streaming completes")` + +#### Scenario: Adapter get_messages retrieves history +- **WHEN** `ACPClientAdapter.get_messages()` is called after the prompt completes +- **THEN** the adapter SHALL call `api.get_messages()` and return the message history + +### Requirement: ACPClientHandler SHALL bifurcate state updates and stream data + +The `ACPClientHandler.session_update()` method SHALL process state updates (model, mode, config, commands) in-place and push only stream-data updates (text chunks, tool calls, thoughts) to the async queue. State updates (`CurrentModeUpdate`, `CurrentModelUpdate`, `ConfigOptionUpdate`, `AvailableCommandsUpdate`) SHALL NOT be pushed to the stream queue — they SHALL be processed by the handler directly, preserving the existing state tracking behavior. Stream-data updates (`AgentMessageChunk`, `ToolCallStart`, `ToolCallComplete`, etc.) SHALL be pushed to the async queue. + +#### Scenario: State update processed in-place +- **WHEN** `ACPClientHandler.session_update()` receives a `CurrentModelUpdate` notification +- **THEN** the handler SHALL update its internal model state directly +- **AND** SHALL NOT push the update to the async queue + +#### Scenario: Stream data pushed to queue +- **WHEN** `ACPClientHandler.session_update()` receives an `AgentMessageChunk` notification +- **THEN** the handler SHALL push the notification to the async queue +- **AND** SHALL NOT process it as a state update + +### Requirement: ACPClientAdapter async queue SHALL be bounded + +The async queue in `ACPClientAdapter` SHALL have a `max_buffer_size` of 1000 items to prevent unbounded memory growth. If the queue is full when a new notification arrives, the adapter SHALL apply backpressure by blocking the push until the consumer drains items. + +#### Scenario: Queue backpressure +- **WHEN** the async queue has 1000 items and a new notification arrives +- **THEN** the push operation SHALL block until the consumer dequeues at least one item +- **AND** the ACP server SHALL be effectively throttled until the consumer catches up + +### Requirement: ACPClientAdapter SHALL reject concurrent prompts + +The `ACPClientAdapter` SHALL reject a new `prompt()` call while a previous prompt is still streaming. ACP sessions typically allow one active prompt at a time. This matches the current behavior where `ACPAgentAPI.prompt()` blocks until completion. + +#### Scenario: Concurrent prompt rejected +- **WHEN** `adapter.prompt()` is called while a previous prompt's background task is still running +- **THEN** the adapter SHALL raise `RuntimeError("Prompt already in progress")` +- **AND** SHALL NOT launch a new background task + +### Requirement: ACPTurn SHALL use ACPClientAdapter instead of cast hack + +The `ACPAgent.create_turn()` method SHALL construct an `ACPClientAdapter` wrapping `self._api` and pass it to `ACPTurn`. The `cast("ACPClientProtocol", self._api)` hack SHALL be removed. `ACPTurn.execute()` SHALL call `adapter.prompt()`, then iterate `adapter.stream_events()`, then access `adapter.stop_reason`, and finally call `adapter.get_messages()`. + +#### Scenario: ACPTurn executes successfully +- **WHEN** `ACPTurn.execute()` is called +- **THEN** the turn SHALL call `adapter.prompt()` (returns None, non-blocking) +- **AND** SHALL iterate `adapter.stream_events()` yielding each notification as a `RichAgentStreamEvent` +- **AND** SHALL access `adapter.stop_reason` after the stream completes +- **AND** SHALL call `adapter.get_messages()` after the stream completes +- **AND** SHALL return the final `ChatMessage[str]` result + +#### Scenario: ACPTurn no longer uses cast +- **WHEN** `ACPAgent.create_turn()` is called +- **THEN** it SHALL construct `ACPClientAdapter(self._api)` +- **AND** SHALL NOT use `cast("ACPClientProtocol", self._api)` diff --git a/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/acp-proxy-chain/spec.md b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/acp-proxy-chain/spec.md new file mode 100644 index 000000000..dc23bbb87 --- /dev/null +++ b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/acp-proxy-chain/spec.md @@ -0,0 +1,119 @@ +## ADDED Requirements + +### Requirement: Conductor SHALL manage proxy chain lifecycle + +The `Conductor` class SHALL manage the lifecycle of a proxy chain, including spawning the terminal agent subprocess, initializing proxies via `proxy/initialize`, and routing messages bidirectionally via `proxy/successor`. The Conductor SHALL inherit from `MessageNode[ChatMessage, ChatMessage[str]]` and expose a `_step` property for graph-based execution. + +#### Scenario: Conductor initializes proxy chain +- **WHEN** a Conductor is created with a list of proxy configs and a terminal agent config +- **THEN** the Conductor SHALL spawn the terminal agent subprocess +- **AND** SHALL call `proxy/initialize` on each proxy in order from client toward terminal agent (P1 first, P2 next, ..., terminal agent last) +- **AND** SHALL establish `proxy/successor` forwarding between adjacent proxies +- **AND** SHALL send `initialize` (standard ACP method) to the terminal agent (last component) +- **AND** SHALL return a ready signal when the chain is fully initialized + +#### Scenario: Conductor with zero proxies +- **WHEN** a Conductor is created with no proxy configs (empty `proxy_chain` list) +- **THEN** the Conductor SHALL connect directly to the terminal agent +- **AND** SHALL NOT send any `proxy/initialize` or `proxy/successor` messages + +#### Scenario: Conductor cleanup on shutdown +- **WHEN** the Conductor is shut down (async context manager exit) +- **THEN** the Conductor SHALL terminate the terminal agent subprocess +- **AND** SHALL clean up all proxy connections in reverse order +- **AND** SHALL ensure no orphaned subprocesses remain + +### Requirement: Proxy protocol SHALL define proxy/initialize and proxy/successor + +The `Proxy` protocol SHALL define two methods following the ACP proxy chain RFD: +- `proxy_initialize()`: Called during chain setup to signal that a successor exists. Returns proxy capabilities including `intercepted_methods` list (message types the proxy intercepts). +- `proxy_successor(method, params, meta)`: Called to forward a message to the successor (next proxy or terminal agent). The proxy MAY inspect, modify, or block the message before forwarding. + +#### Scenario: Proxy receives proxy/initialize +- **WHEN** the Conductor calls `proxy/initialize` on a proxy +- **THEN** the proxy SHALL return its capabilities including `intercepted_methods` (list of ACP method names it intercepts) +- **AND** SHALL prepare its internal state for chain operation + +#### Scenario: Proxy forwards message via proxy/successor +- **WHEN** a proxy receives a `proxy/successor` call with method, params, and meta +- **THEN** the proxy MAY inspect the method and params +- **AND** if the proxy has interception logic for this message type (declared in `intercepted_methods`), it SHALL apply the interception +- **AND** SHALL forward the (possibly modified) message to its successor +- **OR** SHALL return a blocking response if the interception denies the message + +#### Scenario: Proxy passthrough for unregistered message types +- **WHEN** a proxy receives a `proxy/successor` call for a message type not in its `intercepted_methods` list +- **THEN** the proxy SHALL forward the raw message to its successor without deserializing the params +- **AND** SHALL NOT pay any serialization/deserialization cost + +### Requirement: ProxySideConnection SHALL wrap proxy wire communication + +The `ProxySideConnection` class SHALL wrap a `Connection` instance to provide proxy-specific message handling. It SHALL listen for `proxy/initialize` and `proxy/successor` requests and dispatch them to the `Proxy` implementation. It SHALL be analogous to `AgentSideConnection` and `ClientSideConnection`. + +#### Scenario: ProxySideConnection receives proxy/successor +- **WHEN** a `ProxySideConnection` receives a `proxy/successor` JSON-RPC request +- **THEN** it SHALL dispatch the method, params, and meta to the Proxy implementation +- **AND** SHALL return the Proxy's response to the caller + +### Requirement: Conductor SHALL determine terminal vs proxy by chain position + +The Conductor SHALL determine which components are proxies vs terminal agent based on **chain position** from configuration. The last component in the chain is the terminal agent; all others are proxies. The Conductor sends `proxy/initialize` to all proxy components and `initialize` to the terminal agent (last component). The Conductor does NOT detect terminal vs proxy status from responses — it knows from configuration. + +#### Scenario: Terminal agent receives initialize +- **WHEN** the Conductor initializes the chain and the component is the last in the chain (terminal agent) +- **THEN** the Conductor SHALL send `initialize` (standard ACP method) +- **AND** SHALL NOT send `proxy/initialize` or `proxy/successor` to it +- **AND** SHALL send standard ACP methods (`session/prompt`, `session/update`) directly + +#### Scenario: Proxy receives proxy/initialize +- **WHEN** the Conductor initializes the chain and the component is not the last (proxy) +- **THEN** the Conductor SHALL send `proxy/initialize` +- **AND** SHALL route subsequent messages through `proxy/successor` + +### Requirement: YAML proxy_chain configuration + +The system SHALL support a `proxy_chain` section in ACP agent configuration. Each entry SHALL have a `type` field that maps to a registered proxy implementation. When `proxy_chain` is omitted, the Conductor SHALL run with zero proxies. + +#### Scenario: Agent with proxy chain +- **WHEN** an ACP agent config includes a `proxy_chain` section with one or more proxy entries +- **THEN** the Conductor SHALL instantiate each proxy in order +- **AND** SHALL initialize the chain with the terminal agent at the end + +#### Scenario: Agent without proxy chain +- **WHEN** an ACP agent config does not include a `proxy_chain` section +- **THEN** the Conductor SHALL connect directly to the terminal agent with no proxies + +### Requirement: Conductor SHALL use structured concurrency for subprocess management + +The Conductor SHALL use anyio task groups for structured concurrency when spawning the terminal agent subprocess and managing proxy connections. Subprocess cleanup SHALL run in a `finally` block to prevent orphaned processes. + +#### Scenario: Subprocess crash during operation +- **WHEN** the terminal agent subprocess crashes during operation +- **THEN** the Conductor SHALL detect the crash via the connection's task supervisor +- **AND** SHALL clean up all proxy connections +- **AND** SHALL raise an appropriate error to the caller + +### Requirement: Proxy chain error propagation + +Errors in a proxy SHALL produce a JSON-RPC error response forwarded back through the chain to the client. The Conductor SHALL NOT silently skip failed proxies — a security hook proxy failing silently is dangerous. + +#### Scenario: Proxy exception during proxy/successor +- **WHEN** a proxy raises an exception during `proxy/successor` processing +- **THEN** the Conductor SHALL construct a JSON-RPC error response with the exception details +- **AND** SHALL forward the error response back through the chain to the predecessor +- **AND** SHALL NOT skip the proxy or continue with default behavior + +#### Scenario: Proxy crash during initialization +- **WHEN** a proxy crashes during `proxy/initialize` +- **THEN** the Conductor SHALL abort chain initialization +- **AND** SHALL clean up all already-initialized proxies and the terminal agent +- **AND** SHALL raise an initialization error to the caller + +### Requirement: Proxy hot-swap is out of scope + +The Conductor SHALL NOT support hot-swapping proxies at runtime (adding/removing proxies without restarting the chain). This is explicitly out of scope for this change. The design should not preclude it, but it will not be implemented. + +#### Scenario: Hot-swap not supported +- **WHEN** a user attempts to modify the proxy chain at runtime +- **THEN** the system SHALL raise `NotImplementedError("Proxy hot-swap is not supported")` +- **AND** the proxy chain SHALL remain unchanged diff --git a/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/acp-proxy-impls/spec.md b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/acp-proxy-impls/spec.md new file mode 100644 index 000000000..2a586cc72 --- /dev/null +++ b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/acp-proxy-impls/spec.md @@ -0,0 +1,107 @@ +## ADDED Requirements + +### Requirement: HookProxy SHALL wrap existing Hook implementations as proxy components + +The `HookProxy` class SHALL implement the `Proxy` protocol and wrap one or more `Hook` instances. It SHALL handle **all 4 hook types** (`pre_turn`, `post_turn`, `pre_tool_use`, `post_tool_use`) by mapping ACP wire messages to `HookInput` events and applying `HookResult` modifications back to the ACP message stream. The existing `Hook` base class, `CallableHook`, `CommandHook`, `PromptHook`, `HookInput`, and `HookResult` types SHALL be reused without modification. + +Hook semantics are **per-turn** (as established by `unify-hook-system`): `pre_turn` fires before each prompt is forwarded to the terminal agent, `post_turn` fires after each turn's response is received. In a multi-turn `RunHandle` (with steer/followup), these fire for each turn, not just the first and last. + +#### Scenario: HookProxy intercepts session/prompt as pre_turn +- **WHEN** a `HookProxy` with a `pre_turn` hook receives a `session/prompt` message via `proxy/successor` +- **THEN** the proxy SHALL construct `HookInput(event="pre_turn", prompt=, agent_name=)` +- **AND** SHALL execute the wrapped hook(s) +- **AND** if `HookResult.decision == "deny"`, SHALL NOT forward the message and SHALL return a denial response (blocking, not advisory) +- **AND** if `HookResult.additional_context` is set, SHALL prepend it to the prompt before forwarding +- **AND** if `HookResult.decision == "allow"` (default), SHALL forward the (possibly modified) message to the successor + +#### Scenario: HookProxy intercepts tool call as pre_tool_use +- **WHEN** a `HookProxy` with a `pre_tool_use` hook receives a `session/update` message containing a `ToolCallStart` update +- **THEN** the proxy SHALL construct `HookInput(event="pre_tool_use", tool_name=, tool_input=)` +- **AND** SHALL execute the wrapped hook(s) +- **AND** if `HookResult.modified_input` is set, SHALL replace the tool input in the update before forwarding +- **AND** if `HookResult.decision == "deny"`, SHALL NOT forward the tool call (blocking — tool call never reaches terminal agent) + +#### Scenario: HookProxy intercepts tool result as post_tool_use +- **WHEN** a `HookProxy` with a `post_tool_use` hook receives a `session/update` message containing a `ToolCallComplete` update +- **THEN** the proxy SHALL construct `HookInput(event="post_tool_use", tool_name=, tool_output=)` +- **AND** SHALL execute the wrapped hook(s) +- **AND** if `HookResult.modified_output` is set, SHALL replace the tool output in the update before forwarding +- **AND** if `HookResult.additional_context` is set, SHALL inject it into the conversation + +#### Scenario: HookProxy intercepts agent response as post_turn +- **WHEN** a `HookProxy` with a `post_turn` hook receives the JSON-RPC response to the original `session/prompt` request (correlated by request ID) +- **THEN** the proxy SHALL construct `HookInput(event="post_turn", result=)` using the accumulated agent response from `AgentMessageChunk` updates received during the turn +- **AND** SHALL execute the wrapped hook(s) +- **AND** if `HookResult.modified_output` is set, SHALL replace the output content in the response before forwarding +- **AND** SHALL NOT fire `post_turn` on individual `AgentMessageChunk` updates — only on the correlated JSON-RPC response + +#### Scenario: HookProxy with no matching hooks for message type +- **WHEN** a `HookProxy` receives a message type that no wrapped hook matches +- **THEN** the proxy SHALL forward the message without modification (passthrough) + +### Requirement: HookProxy and HookAwareTurn SHALL coexist via _hooks=None + +Two hook firing mechanisms coexist for ACP agents. The Conductor controls which mechanism is active by controlling whether hooks are passed to `ACPTurn`. + +- **HookAwareTurn (v1)**: Fires all 4 hook types in-process within `ACPTurn.execute()`. Tool hooks are advisory (cannot block subprocess). Active when no `HookProxy` is in the proxy chain. Implemented by `unify-hook-system`. +- **HookProxy (v2)**: Fires all 4 hook types at wire-level in the proxy chain. All hooks are blocking (intercepts before terminal agent). Active when `HookProxy` is in the proxy chain. Implemented by this change. + +When `HookProxy` is in the chain, the Conductor SHALL pass `_hooks=None` to `ACPTurn`. `HookAwareTurn`'s guard (`if self._hooks is None: return None`) skips all hook firing. This approach avoids interaction with the per-turn `hooks_fired` clearing logic from `unify-hook-system`. + +#### Scenario: HookProxy active, HookAwareTurn disabled +- **WHEN** a Conductor has a `HookProxy` in the proxy chain +- **THEN** the Conductor SHALL pass `_hooks=None` to `ACPTurn` +- **AND** `HookAwareTurn` on `ACPTurn` SHALL skip all hook firing (guard: `_hooks is None`) +- **AND** hooks SHALL fire at wire-level via `HookProxy` (blocking) + +#### Scenario: No HookProxy, HookAwareTurn active +- **WHEN** a Conductor has no `HookProxy` in the proxy chain +- **THEN** the Conductor SHALL pass the agent's `AgentHooks` to `ACPTurn` +- **AND** `HookAwareTurn` on `ACPTurn` SHALL fire all 4 hook types in-process (advisory for tool hooks) + +#### Scenario: Conductor auto-inserts HookProxy +- **WHEN** an ACP agent has hooks configured and no explicit `HookProxy` in `proxy_chain` +- **THEN** the Conductor SHALL auto-insert a `HookProxy` at chain position 0 (closest to client) +- **AND** the auto-inserted `HookProxy` SHALL wrap the agent's configured hooks +- **AND** the Conductor SHALL pass `_hooks=None` to `ACPTurn` (HookAwareTurn disabled) + +### Requirement: ContextInjectionProxy SHALL inject system context into prompts + +The `ContextInjectionProxy` SHALL intercept `session/prompt` messages and prepend configured context (AGENTS.md content, skill instructions, system prompt customizations) to the prompt text before forwarding to the successor. This is separate from `HookProxy`'s `pre_turn` `additional_context` — `ContextInjectionProxy` handles declarative context sources (files, skills), while `HookProxy` handles dynamic hook-driven context injection. + +#### Scenario: Context injection with AGENTS.md +- **WHEN** a `ContextInjectionProxy` with `agents_md: true` receives a `session/prompt` +- **THEN** the proxy SHALL read the AGENTS.md file from the agent's working directory +- **AND** SHALL prepend the content to the prompt as system context +- **AND** SHALL forward the modified prompt to the successor + +#### Scenario: Context injection with skills +- **WHEN** a `ContextInjectionProxy` with configured skills receives a `session/prompt` +- **THEN** the proxy SHALL load skill instructions from the configured skill paths +- **AND** SHALL inject them as context metadata in the prompt +- **AND** SHALL forward the modified prompt to the successor + +### Requirement: ToolProviderProxy SHALL expose tools via MCP-over-ACP + +The `ToolProviderProxy` SHALL intercept `session/prompt` or `session/update` messages to inject tool definitions. It SHALL reuse `AcpMcpTransport` and `AcpMcpConnectionManager` for MCP-over-ACP communication. Tools provided by this proxy SHALL be available to the terminal agent as if they were native tools. + +#### Scenario: Tool provider injects tools +- **WHEN** a `ToolProviderProxy` with configured MCP servers is initialized +- **THEN** the proxy SHALL connect to the configured MCP servers via `AcpMcpTransport` +- **AND** SHALL advertise tool capabilities during `proxy/initialize` +- **AND** SHALL intercept tool call requests from the terminal agent and route them to the appropriate MCP server + +#### Scenario: Tool provider handles tool call +- **WHEN** the terminal agent requests a tool call that belongs to the proxy's MCP servers +- **THEN** the proxy SHALL route the tool call to the appropriate MCP server via `AcpMcpConnectionManager` +- **AND** SHALL return the tool result to the terminal agent + +### Requirement: Proxy implementations SHALL be registrable via type discriminator + +Each built-in proxy implementation SHALL register a unique `type` string that maps to its class. The YAML configuration `proxy_chain[].type` field SHALL use this string to instantiate the correct proxy class. New proxy implementations SHALL be registrable via an entry point or registration function. + +#### Scenario: Proxy type registration +- **WHEN** the system loads proxy chain configuration +- **THEN** it SHALL look up each `type` value in the proxy registry +- **AND** SHALL instantiate the corresponding proxy class with the config entry +- **AND** SHALL raise a clear error if the type is not registered diff --git a/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/acp-server/spec.md b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/acp-server/spec.md new file mode 100644 index 000000000..2f4364310 --- /dev/null +++ b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/acp-server/spec.md @@ -0,0 +1,45 @@ +## MODIFIED Requirements + +### Requirement: ACP cancel_session does not kill the RunHandle + +`cancel_session()` SHALL only call `SessionController.cancel_run_for_session()`. It SHALL NOT call `run_handle.fail()`. Legacy clients blocking on `_turn_complete_event.wait()` SHALL unblock when the cancelled turn finishes — `ACPTurn.execute()` returns WITHOUT yielding `StreamCompleteEvent`, and `start()` publishes `RunFailedEvent` then sets `_turn_complete_event`. + +- `cancel_session()` SHALL NOT publish `RunFailedEvent` directly — `start()` publishes it when it detects `run_ctx.cancelled` after the turn +- The event consumer SHALL still send `session/update` with `turn_complete` and `stop_reason="cancelled"` after the cancelled turn finishes +- `handle_prompt()` SHALL wait on `run_handle._turn_complete_event` instead of `run_handle.complete_event` for legacy clients + +#### Scenario: Cancel during active ACP turn +- **WHEN** `cancel()` is called while an ACP agent turn is executing +- **THEN** `run_ctx.cancelled` is set to `True` +- **AND** `ACPTurn.execute()` catches `CancelledError` from the stream iteration +- **AND** returns WITHOUT yielding `StreamCompleteEvent` +- **AND** `start()` publishes `RunFailedEvent(exception=RuntimeError("Run cancelled"))` +- **AND** the event converter emits a single `TurnCompleteUpdate(stop_reason="cancelled")` + +#### Scenario: Cancel during idle +- **WHEN** `cancel()` is called while the RunHandle is idle (waiting on `_idle_event`) +- **THEN** `run_ctx.cancelled` is set to `True` +- **AND** `_idle_event` is set to unblock the idle wait +- **AND** the RunHandle remains alive + +#### Scenario: Cancel then new prompt +- **WHEN** a run is cancelled and then a new prompt arrives via `steer()` or `followup()` +- **THEN** the message is queued in `_message_queue` +- **AND** `_idle_event` is set to wake the idle loop +- **AND** `start()` wakes, resets `run_ctx.cancelled` to `False` BEFORE creating the new turn +- **AND** a new turn is created and executed normally + +### Requirement: AgentPoolACPAgent SHALL operate as terminal agent behind Conductor + +`AgentPoolACPAgent` SHALL be refactored to operate as a terminal agent in the Conductor's proxy chain. It SHALL respond to standard `initialize` (not `proxy/initialize`), process `session/prompt` directly, and emit `session/update` notifications. The legacy `ACPSession.process_prompt()` dual path SHALL be removed — all prompt processing SHALL route through `ACPProtocolHandler.handle_prompt()`. + +#### Scenario: AgentPoolACPAgent as terminal agent +- **WHEN** a Conductor initializes the chain and AgentPoolACPAgent is the terminal component +- **THEN** AgentPoolACPAgent SHALL respond to `initialize` with its capabilities +- **AND** SHALL NOT respond to `proxy/initialize` +- **AND** SHALL process `session/prompt` directly without proxy/successor wrapping + +#### Scenario: Legacy process_prompt removed +- **WHEN** a prompt is received by the ACP server +- **THEN** it SHALL route exclusively through `ACPProtocolHandler.handle_prompt()` +- **AND** SHALL NOT fall through to the legacy `ACPSession.process_prompt()` path diff --git a/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/acp-single-execution-path/spec.md b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/acp-single-execution-path/spec.md new file mode 100644 index 000000000..5f33f7c8f --- /dev/null +++ b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/acp-single-execution-path/spec.md @@ -0,0 +1,42 @@ +## MODIFIED Requirements + +### Requirement: ACP prompt processing SHALL use SessionPool exclusively + +The ACP server SHALL route all prompt processing through `SessionPool.run_stream()`. There SHALL be no fallback path that calls `agent.run_stream()` directly. If `SessionPool` is unavailable, the system SHALL raise an error rather than silently falling back. With the proxy chain refactor, `SessionPool.run_stream()` SHALL create an `ACPTurn` (via `agent.create_turn()`) and execute it through `TurnRunner`, which is now functional for ACP agents thanks to `ACPClientAdapter`. + +#### Scenario: SessionPool available with ACP agent +- **WHEN** `SessionPool.run_stream()` is called with an ACP agent +- **THEN** the system SHALL create an `ACPTurn` via `agent.create_turn()` +- **AND** the `ACPTurn` SHALL use `ACPClientAdapter` to interface with the ACP subprocess +- **AND** SHALL execute the turn through `TurnRunner` +- **AND** SHALL NOT fall back to direct `agent.run_stream()` invocation + +#### Scenario: SessionPool unavailable +- **WHEN** ACP prompt processing is called and `SessionPool` is NOT available +- **THEN** the system SHALL raise a clear error indicating that SessionPool is required for ACP prompt processing + +### Requirement: Legacy acp_agent.prompt() dead code SHALL be removed + +The dead code path in `acp_agent.py` that calls `session.process_prompt()` when `_protocol_handler.handle_prompt()` returns `None` SHALL be removed. `handle_prompt()` always returns a `PromptResponse`, making this path unreachable. Additionally, the `_stream_events()` inline bypass in `ACPAgent` SHALL be removed — all streaming SHALL go through `ACPTurn.execute()`. + +#### Scenario: Prompt routing +- **WHEN** `acp_agent.prompt()` receives a prompt +- **THEN** it SHALL route exclusively through `_protocol_handler.handle_prompt()` and NOT fall through to the legacy `session.process_prompt()` path + +#### Scenario: Streaming uses ACPTurn +- **WHEN** `ACPAgent.run_stream()` is called +- **THEN** it SHALL use `ACPTurn.execute()` for streaming +- **AND** SHALL NOT use `_stream_events()` inline bypass +- **AND** SHALL NOT use `poll_acp_events()` polling + +### Requirement: ACPSessionManager SHALL separate lifecycle from protocol state + +`ACPSessionManager._active` SHALL be renamed to `_acp_sessions: dict[str, ACPSession]`. Session lifecycle queries (existence, agent name, run status) SHALL be delegated to `SessionController.get_session()`. The `_acp_sessions` dict SHALL only store `ACPSession` runtime objects with protocol-specific state. + +#### Scenario: Session lookup +- **WHEN** `ACPSessionManager.get_session(session_id)` is called +- **THEN** the system SHALL first check `SessionController.get_session(session_id)` for lifecycle state, then look up the `ACPSession` from `_acp_sessions` if the session is alive + +#### Scenario: Pool swap cleanup +- **WHEN** a pool swap occurs +- **THEN** `_acp_sessions` SHALL be iterated for `ACPSession` cleanup, while lifecycle clearing SHALL be delegated to `SessionController` diff --git a/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/session-orchestration/spec.md b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/session-orchestration/spec.md new file mode 100644 index 000000000..a2effba56 --- /dev/null +++ b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/specs/session-orchestration/spec.md @@ -0,0 +1,71 @@ +## ADDED Requirements + +### Requirement: TurnRunner SHALL work with ACP agents via functional ACPTurn + +`TurnRunner` SHALL create and execute `ACPTurn` instances for ACP agents. Previously, `TurnRunner` was broken for ACP agents because `ACPAgent.create_turn()` returned an `ACPTurn` that would crash at runtime due to the `cast()` hack. With `ACPClientAdapter`, `ACPTurn` is now functional, and `TurnRunner` SHALL use it as the single execution path. + +#### Scenario: TurnRunner executes ACP agent turn +- **WHEN** `TurnRunner` receives a prompt for an ACP agent session +- **THEN** it SHALL call `agent.create_turn()` to get an `ACPTurn` +- **AND** the `ACPTurn` SHALL use `ACPClientAdapter` for ACP communication +- **AND** `TurnRunner` SHALL execute the turn via `turn.execute()` +- **AND** SHALL stream events through the EventBus as `RichAgentStreamEvent` + +#### Scenario: TurnRunner handles ACP agent cancellation +- **WHEN** a run is cancelled while `TurnRunner` is executing an `ACPTurn` +- **THEN** `ACPTurn.execute()` SHALL catch `CancelledError` from the stream iteration +- **AND** SHALL return without yielding `StreamCompleteEvent` +- **AND** `TurnRunner` SHALL publish `RunFailedEvent` with cancellation reason + +### Requirement: RunHandle SHALL support ACP agent runs + +`RunHandle` SHALL track ACP agent runs with the same lifecycle as native agent runs: pending → running → completed/failed. The `RunHandle.complete_event` SHALL be set after ACP turn cleanup finishes. `close_session()` SHALL await this event with a timeout for graceful shutdown. + +#### Scenario: ACP run completes normally +- **WHEN** an `ACPTurn` completes successfully +- **THEN** `RunHandle.status` SHALL transition to `completed` +- **AND** `complete_event` SHALL be set +- **AND** `StreamCompleteEvent` SHALL be published to the EventBus + +#### Scenario: ACP run fails +- **WHEN** an `ACPTurn` raises an exception +- **THEN** `RunHandle.status` SHALL transition to `failed` +- **AND** `RunFailedEvent` SHALL be published to the EventBus +- **AND** `complete_event` SHALL be set after cleanup + +## MODIFIED Requirements + +### Requirement: RunHandle cancel interrupts current turn, not the run loop + +`RunHandle.cancel()` SHALL set `run_ctx.cancelled = True` and wake `_idle_event` to unblock idle waits. `cancel()` SHALL call `agent._interrupt()` which cancels only the `_iteration_task` (the LLM API call task for native agents, or the stream iteration task for ACP agents). `cancel()` SHALL NOT cancel `run_ctx.current_task` (the `start()` task). After cancellation, the `start()` loop SHALL return to idle state and accept new `steer()` / `followup()` messages. + +- `cancel()` SHALL be idempotent — calling it multiple times has no additional effect +- `cancel()` SHALL NOT call `fail()` or set `complete_event` — the run stays alive +- For ACP agents, `agent._interrupt()` SHALL cancel the stream iteration task (the `adapter.stream_events()` consumer), not the background prompt task +- `agent._iteration_task` SHALL be set before each turn execution and cleared after + +#### Scenario: Cancel during active ACP turn +- **WHEN** `cancel()` is called while an ACP agent turn is executing +- **THEN** `run_ctx.cancelled` is set to `True` +- **AND** `agent._interrupt()` cancels the stream iteration task +- **AND** `ACPTurn.execute()` catches `CancelledError` from the stream +- **AND** returns WITHOUT yielding `StreamCompleteEvent` +- **AND** `start()` exits the turn loop, detects `run_ctx.cancelled`, publishes `RunFailedEvent` +- **AND** the event converter emits a `TurnCompleteUpdate(stop_reason="cancelled")` +- **AND** `start()` sets `_turn_complete_event` and returns to idle state +- **AND** `run_ctx.current_task` (the `start()` task) is NOT cancelled + +#### Scenario: Cancel during idle +- **WHEN** `cancel()` is called while the RunHandle is idle (waiting on `_idle_event`) +- **THEN** `run_ctx.cancelled` is set to `True` +- **AND** `_idle_event` is set to unblock the idle wait +- **AND** `start()` wakes up, checks `_closing` (not set), checks `run_ctx.cancelled` +- **AND** since `cancelled` is `True` and no prompts are queued, goes back to idle +- **AND** the RunHandle remains alive + +#### Scenario: Cancel then new prompt +- **WHEN** a run is cancelled and then a new prompt arrives via `steer()` or `followup()` +- **THEN** the message is queued in `_message_queue` +- **AND** `_idle_event` is set to wake the idle loop +- **AND** `start()` wakes, resets `run_ctx.cancelled` to `False` BEFORE creating the new turn +- **AND** a new turn is created and executed normally diff --git a/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/tasks.md b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/tasks.md new file mode 100644 index 000000000..b8d939630 --- /dev/null +++ b/openspec/changes/archive/2026-07-08-acp-proxy-chain-refactor/tasks.md @@ -0,0 +1,95 @@ +## 1. ACPTurn Fix — ACPClientAdapter (Phase 1) + +- [ ] 1.1 Create `src/agentpool/agents/acp_agent/adapter.py` with `ACPClientAdapter` class implementing modified `ACPClientProtocol` +- [ ] 1.2 Redefine `ACPClientProtocol` — `prompt()` returns `None`, `stream_events()` takes no args (returns `AsyncIterator[SessionUpdate]`), add `stop_reason` property +- [ ] 1.3 Implement `ACPClientAdapter.prompt()` — launch `api.prompt()` as background task, return `None` (non-blocking) +- [ ] 1.4 Implement `ACPClientAdapter.stream_events()` — return async iterator from `asyncio.Queue(maxsize=1000)` that `session_update()` pushes stream-data updates to +- [ ] 1.5 Implement `ACPClientAdapter.stop_reason` property — returns `PromptResponse.stop_reason` after background task completes, raises `RuntimeError` if accessed before completion +- [ ] 1.6 Implement `ACPClientAdapter.get_messages()` — call `api.get_messages()` after prompt completes +- [ ] 1.7 Implement `ACPClientAdapter` concurrent prompt rejection — raise `RuntimeError("Prompt already in progress")` if `prompt()` called while previous is streaming +- [ ] 1.8 Modify `ACPClientHandler.session_update()` — process state updates (model/mode/config/commands) in-place, push only stream-data updates (text chunks, tool calls, thoughts) to async queue +- [ ] 1.9 Remove `poll_acp_events()` and 50ms timeout loop from `acp_agent.py` +- [ ] 1.10 Fix `ACPAgent.create_turn()` — replace `cast("ACPClientProtocol", self._api)` with `ACPClientAdapter(self._api)` +- [ ] 1.11 Fix `ACPTurn.execute()` — use `adapter.prompt()`, iterate `adapter.stream_events()`, access `adapter.stop_reason`, call `adapter.get_messages()` +- [ ] 1.12 Remove `_stream_events()` inline bypass from `ACPAgent.run_stream()` — route through `ACPTurn.execute()` +- [ ] 1.13 Delete `ACPSessionState` deque mechanism from `session_state.py` (preserve model/mode/config state tracking in a renamed `ACPState` dataclass) +- [ ] 1.14 Write unit tests for `ACPClientAdapter` (prompt non-blocking, stream_events queue, stop_reason property, get_messages, concurrent prompt rejection) +- [ ] 1.15 Write unit tests for `ACPClientHandler` state update bifurcation (state updates in-place, stream data to queue) +- [ ] 1.16 Write integration test: ACPAgent.run_stream() uses ACPTurn (no polling, no _stream_events bypass) + +## 2. Proxy Protocol & Conductor (Phase 2) + +- [ ] 2.1 Create `src/acp/proxy/__init__.py` package +- [ ] 2.2 Create `src/acp/proxy/protocol.py` — `Proxy` typing.Protocol with `proxy_initialize()` (returns `intercepted_methods` list) and `proxy_successor()` methods +- [ ] 2.3 Create `src/acp/proxy/connection.py` — `ProxySideConnection` wrapping `Connection` for proxy-side dispatch +- [ ] 2.4 Create `src/acp/proxy/constants.py` — wire method name constants (`PROXY_INITIALIZE`, `PROXY_SUCCESSOR`) +- [ ] 2.5 Create `src/acp/conductor.py` — `Conductor(MessageNode[ChatMessage, ChatMessage[str]])` class +- [ ] 2.6 Implement Conductor subprocess spawning using anyio task groups (structured concurrency) +- [ ] 2.7 Implement Conductor chain initialization — call `proxy/initialize` on each proxy from client toward terminal agent, then `initialize` on terminal agent (last component) +- [ ] 2.8 Implement Conductor terminal/proxy detection by chain position (last component = terminal, receives `initialize`; all others = proxies, receive `proxy/initialize`) +- [ ] 2.9 Implement Conductor message routing — bidirectional `proxy/successor` forwarding between adjacent proxies +- [ ] 2.10 Implement Conductor passthrough optimization — use `intercepted_methods` from `proxy/initialize` response to skip deserialization for unregistered message types +- [ ] 2.11 Implement Conductor error propagation — proxy exceptions produce JSON-RPC error responses forwarded back through chain, no silent skipping +- [ ] 2.12 Implement Conductor `_step` property for pydantic-graph integration +- [ ] 2.13 Implement Conductor async context manager — cleanup subprocesses and connections in `finally` block +- [ ] 2.14 Write unit tests for Conductor chain initialization (zero proxies, N proxies, terminal detection by position) +- [ ] 2.15 Write unit tests for Conductor message routing (forward, passthrough, intercept, error propagation) + +## 3. ACPAgent Rewrite (Phase 3) + +- [ ] 3.1 Rewrite `ACPAgent.__init__()` — accept optional `proxy_chain` config, create Conductor instead of direct subprocess +- [ ] 3.2 Change `ACPAgent` output type from `str` to `ChatMessage[str]` for `MessageNode` contract compliance +- [ ] 3.3 Implement `ACPAgent.create_turn()` — construct `ACPClientAdapter` from Conductor's connection, create `ACPTurn` +- [ ] 3.4 Implement `ACPAgent.run_stream()` — delegate to `ACPTurn.execute()` via graph Step +- [ ] 3.5 Add `use_conductor` feature flag to `ACPAgentConfig` for backward compatibility (default: true) +- [ ] 3.6 Create `ProxyChainConfig` Pydantic model in `src/agentpool/models/` with `type` discriminator field +- [ ] 3.7 Add `proxy_chain` optional field to `ACPAgentConfig` model +- [ ] 3.8 Update `AgentPool` to pass proxy chain config to ACPAgent during instantiation +- [ ] 3.9 Migrate `ACPAgent` from `ToolManagerBridge` to `ResourceProvider` +- [ ] 3.10 Write integration test: ACPAgent with Conductor + zero proxies (backward compat) +- [ ] 3.11 Write integration test: ACPAgent with Conductor + proxy chain +- [ ] 3.12 Verify all existing ACP agent tests pass with `use_conductor: true` + +## 4. Built-in Proxy Implementations (Phase 4) + +- [ ] 4.1 Create proxy type registry — map string type discriminators to proxy classes +- [ ] 4.2 Create `src/acp/proxy/impls/__init__.py` package +- [ ] 4.3 Implement `HookProxy` — wrap `Hook` instances, handle ALL 4 hook types at wire level +- [ ] 4.4 Implement `HookProxy` pre_turn mapping — `session/prompt` → `HookInput(event="pre_turn")`, apply `additional_context`/`decision` (blocking deny) +- [ ] 4.5 Implement `HookProxy` pre_tool_use mapping — `session/update` ToolCallStart → `HookInput(event="pre_tool_use")`, apply `modified_input`, blocking deny +- [ ] 4.6 Implement `HookProxy` post_tool_use mapping — `session/update` ToolCallComplete → `HookInput(event="post_tool_use")`, apply `modified_output` +- [ ] 4.7 Implement `HookProxy` post_turn mapping — correlate `session/prompt` JSON-RPC request ID with response, fire `HookInput(event="post_turn")` on response arrival (not on individual AgentMessageChunk updates) +- [ ] 4.8 Implement `HookProxy`/`HookAwareTurn` coexistence — Conductor passes `_hooks=None` to ACPTurn when HookProxy is in chain (HookAwareTurn guard skips); passes agent's `AgentHooks` when no HookProxy +- [ ] 4.9 Implement Conductor auto-insert HookProxy — when agent has hooks configured and no explicit HookProxy in chain, auto-insert at position 0 +- [ ] 4.10 Implement `ContextInjectionProxy` — intercept `session/prompt`, prepend AGENTS.md content and skill instructions (separate from HookProxy) +- [ ] 4.11 Implement `ToolProviderProxy` — reuse `AcpMcpTransport`/`AcpMcpConnectionManager` for tool injection via MCP-over-ACP (experimental — depends on unratified MCP-over-ACP RFD) +- [ ] 4.12 Register all built-in proxies in the type registry +- [ ] 4.13 Write unit tests for `HookProxy` (all 4 hook type mappings, deny/allow/modify flows, blocking semantics, JSON-RPC correlation for post_turn) +- [ ] 4.14 Write unit tests for `HookProxy`/`HookAwareTurn` coexistence (_hooks=None, no double-firing) +- [ ] 4.15 Write unit tests for `ContextInjectionProxy` (AGENTS.md injection, skills injection) +- [ ] 4.16 Write unit tests for `ToolProviderProxy` (MCP tool injection, tool call routing) + +## 5. Server-Side Adaptation (Phase 5) + +- [ ] 5.1 Refactor `AgentPoolACPAgent` to operate as terminal agent behind Conductor (respond to `initialize`, not `proxy/initialize`) +- [ ] 5.2 Remove legacy `ACPSession.process_prompt()` dual path — consolidate to `ACPProtocolHandler.handle_prompt()` +- [ ] 5.3 Refactor `ACPEventConverter` to optionally operate as a proxy component in the chain +- [ ] 5.4 Verify `ACPProtocolHandler` (ProtocolEventConsumerMixin) works unchanged with terminal agent mode +- [ ] 5.5 Write integration test: AgentPoolACPAgent as terminal agent in Conductor chain +- [ ] 5.6 Write integration test: nested agentpool (server + client) with zero conversion (passthrough) + +## 6. Cleanup & Migration (Phase 6) + +- [ ] 6.1 Delete `ACPSessionState` deque class and all references (preserve model/mode/config state in renamed `ACPState` or migrated to adapter) +- [ ] 6.2 Delete `poll_acp_events()` function and all references +- [ ] 6.3 Delete `_stream_events()` method from ACPAgent +- [ ] 6.4 Delete `cast("ACPClientProtocol", self._api)` and all dead code in `turn.py` +- [ ] 6.5 Remove `use_conductor` feature flag (make Conductor the only path) +- [ ] 6.6 Simplify `acp_converters.py` — passthrough scenario should be zero conversion +- [ ] 6.7 Remove `ToolManagerBridge` usage and deprecated imports +- [ ] 6.8 Remove `AgentHooks` deprecation warnings related to old ACP path +- [ ] 6.9 Update `AGENTS.md` documentation with proxy chain architecture +- [ ] 6.10 Add YAML config examples for `proxy_chain:` section +- [ ] 6.11 Run full test suite — verify no regressions +- [ ] 6.12 Run `mypy src/` — verify type safety (no `as any`, no `cast` hacks) +- [ ] 6.13 Run `ruff check src/` — verify lint clean diff --git a/src/acp/conductor.py b/src/acp/conductor.py new file mode 100644 index 000000000..68df338fc --- /dev/null +++ b/src/acp/conductor.py @@ -0,0 +1,870 @@ +"""ACP Conductor — manages proxy chain lifecycle and terminal agent subprocess. + +The Conductor inherits from :class:`MessageNode` and owns the +:class:`ACPClientHandler`. It is responsible for spawning the terminal agent +subprocess, wiring JSON-RPC connections, and managing the ACP client handler +lifecycle. Message routing (T9), passthrough optimization (T10), and complete +``_step`` implementation (T11) are added incrementally. + +Design references: +- D1: Conductor inherits ``MessageNode[ChatMessage, ChatMessage[str]]`` +- D5: Passthrough optimization — skip deserialization for unregistered methods +- D8: Conductor owns ``ACPClientHandler`` (transferred from ``ACPAgent``) +""" + +from __future__ import annotations + +import contextlib +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Self, override + +from acp.exceptions import RequestError +from acp.proxy.constants import PROXY_INITIALIZE, PROXY_SUCCESSOR +from agentpool.log import get_logger +from agentpool.messaging.messagenode import MessageNode + + +logger = get_logger(__name__) + + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Mapping + from pathlib import Path + from types import TracebackType + + from anyio.abc import ByteReceiveStream, ByteSendStream, Process + from pydantic_graph import Step + + from acp.client.connection import ClientSideConnection + from acp.proxy.protocol import Proxy + from agentpool.agents.acp_agent.client_handler import ACPClientHandler + from agentpool.hooks.agent_hooks import AgentHooks + from agentpool.messaging import ChatMessage + from agentpool.talk.stats import AggregatedMessageStats, MessageStats + + +@dataclass +class ConductorConfig: + """Configuration for spawning the terminal agent subprocess. + + Attributes: + command: Shell command to execute. + args: Arguments for the command. + env: Environment variables for the subprocess. + cwd: Working directory for the subprocess. + """ + + command: str + """Shell command to execute.""" + args: list[str] = field(default_factory=list) + """Arguments for the command.""" + env: Mapping[str, str] | None = None + """Environment variables for the subprocess.""" + cwd: str | Path | None = None + """Working directory for the subprocess.""" + + +class Conductor(MessageNode[Any, str]): + """Manages proxy chain lifecycle and terminal agent subprocess. + + The Conductor spawns the terminal ACP agent subprocess using anyio task + groups for structured concurrency, wires the JSON-RPC connection to + ``ClientSideConnection``, and owns the :class:`ACPClientHandler` + lifecycle. + + !!! note "Task scope" + + T8: class structure + handler ownership. + T9: chain initialization (``_initialize_chain``). + T10: message routing, passthrough, error propagation. + T11: complete ``_step`` implementation (pending). + """ + + def __init__( + self, + name: str, + command: str, + args: list[str] | None = None, + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + proxy_chain: list[Proxy] | None = None, + client_handler: ACPClientHandler | None = None, + agent_hooks: AgentHooks | None = None, + description: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize the Conductor. + + Args: + name: Conductor name (used as node identity). + command: Subprocess command to spawn the terminal agent. + args: Arguments for the command. + cwd: Working directory for the subprocess. + env: Environment variables for the subprocess. + proxy_chain: Optional list of proxies in the chain. + The last component is the terminal agent; all others are + proxies. When ``None`` or empty, the Conductor connects + directly to the terminal agent. + client_handler: Optional pre-created handler. When ``None``, + the Conductor will own the handler lifecycle but defer + creation until sufficient context is available (T13). + agent_hooks: Optional AgentHooks from the agent. When hooks are + present and no HookProxy is in the chain, a HookProxy is + auto-inserted at position 0. + description: Optional human-readable description. + **kwargs: Additional keyword arguments passed to MessageNode. + """ + super().__init__(name=name, description=description, **kwargs) + + self._config = ConductorConfig( + command=command, + args=list(args) if args else [], + env=env, + cwd=cwd, + ) + self._proxy_chain: list[Proxy] = list(proxy_chain) if proxy_chain else [] + self._client_handler: ACPClientHandler | None = client_handler + self._owns_handler: bool = client_handler is None + self._agent_hooks: AgentHooks | None = agent_hooks + self._has_hook_proxy: bool = False + + # Runtime state — populated during __aenter__ + self._process: Process | None = None + self._reader: ByteReceiveStream | None = None + self._writer: ByteSendStream | None = None + self._connection: ClientSideConnection | None = None + self._exit_stack: contextlib.AsyncExitStack | None = None + self._conductor_initialized: bool = False + + # Chain initialization state — populated during _initialize_chain + self._intercepted_methods: list[list[str]] = [] + self._chain_initialized: bool = False + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def config(self) -> ConductorConfig: + """Get the subprocess configuration.""" + return self._config + + @property + def proxy_chain(self) -> list[Proxy]: + """Get the proxy chain (may be empty).""" + return self._proxy_chain + + @property + def client_handler(self) -> ACPClientHandler | None: + """Get the owned ACPClientHandler, if initialized.""" + return self._client_handler + + @property + def connection(self) -> ClientSideConnection | None: + """Get the client-side connection to the terminal agent.""" + return self._connection + + @property + def process(self) -> Process | None: + """Get the terminal agent subprocess, if spawned.""" + return self._process + + @property + def is_initialized(self) -> bool: + """Whether the Conductor has been entered via ``__aenter__``.""" + return self._conductor_initialized + + @property + def has_hook_proxy(self) -> bool: + """Whether a HookProxy is active in the proxy chain.""" + return self._has_hook_proxy + + def get_turn_hooks(self) -> AgentHooks | None: + """Return hooks for ACPTurn, or None if HookProxy handles them. + + When a HookProxy is in the chain, returns None so HookAwareTurn + skips hook firing (hooks are handled by the proxy). Otherwise, + returns the agent's AgentHooks for normal HookAwareTurn firing. + + Returns: + AgentHooks if no HookProxy, None if HookProxy is active. + """ + if self._has_hook_proxy: + return None + return self._agent_hooks + + def _maybe_auto_insert_hook_proxy(self) -> None: + """Auto-insert HookProxy at position 0 when agent has hooks. + + If the agent has hooks (AgentHooks with has_hooks() == True) and + no HookProxy is already in the chain, creates a HookProxy wrapping + the hooks and inserts it at position 0. + """ + if self._agent_hooks is None or not self._agent_hooks.has_hooks(): + return + + # Check if HookProxy is already in the chain + from acp.proxy.impls.hook_proxy import HookProxy + + for proxy in self._proxy_chain: + if isinstance(proxy, HookProxy): + self._has_hook_proxy = True + return + + # Auto-insert HookProxy at position 0 + hook_proxy = HookProxy(hooks=[self._agent_hooks]) + self._proxy_chain.insert(0, hook_proxy) + self._has_hook_proxy = True + + @override + @property + def agent_type(self) -> str: + """Return the agent-type string for persistence.""" + return "acp" + + # ------------------------------------------------------------------ + # Lifecycle (async context manager) + # ------------------------------------------------------------------ + + @override + async def __aenter__(self) -> Self: + """Start the terminal agent subprocess and initialize the chain. + + Spawns the subprocess using anyio task groups for structured + concurrency, wires the JSON-RPC connection, and creates/initializes + the :class:`ACPClientHandler` if not pre-provided. + """ + await super().__aenter__() + + from acp.client.connection import ClientSideConnection + from acp.client.implementations import NoOpClient + from acp.transports import spawn_stdio_transport + + self._exit_stack = contextlib.AsyncExitStack() + + # Spawn the terminal agent subprocess using anyio structured concurrency. + # spawn_stdio_transport uses anyio internally for process management. + transport_ctx = spawn_stdio_transport( + self._config.command, + *self._config.args, + env=self._config.env, + cwd=self._config.cwd, + ) + reader, writer, process = await self._exit_stack.enter_async_context( + transport_ctx, + ) + self._reader = reader + self._writer = writer + self._process = process + + # Wire the subprocess JSON-RPC connection to ClientSideConnection. + # Use the provided client_handler if available, otherwise NoOpClient. + def client_factory(agent: Any) -> Any: + if self._client_handler is not None: + return self._client_handler + return NoOpClient() + + self._connection = ClientSideConnection(client_factory, writer, reader) + self._exit_stack.push_async_callback(self._connection.close) + + # Create ACPClientHandler if not pre-provided. + # Full handler initialization requires ACPAgent/ACPState context + # which will be wired in T13 (ACPAgent refactor). For now, the + # handler is owned but not fully initialized — this matches the + # task scope (T8: class structure + handler ownership). + if self._client_handler is None and self._owns_handler: + # ACPClientHandler requires an ACPAgent and ACPState at + # construction time. The Conductor will create the handler + # when it has the necessary context (T13 wires this). + # For T8, we store None and allow external injection. + pass + + # Auto-insert HookProxy if agent has hooks and none is in the chain. + self._maybe_auto_insert_hook_proxy() + + # Initialize the proxy chain: call proxy/initialize on each + # proxy, then initialize on the terminal agent. If any + # component fails, clean up all started components. + try: + await self._initialize_chain() + except Exception: + if self._exit_stack is not None: + await self._exit_stack.aclose() + self._exit_stack = None + self._process = None + self._reader = None + self._writer = None + self._connection = None + raise + + # Disable request_permission hooks when HookProxy is active + # to prevent double-firing (hooks handled by proxy, not handler). + if self._has_hook_proxy and self._client_handler is not None: + self._client_handler.set_hooks_enabled(False) + + self._conductor_initialized = True + return self + + @override + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Clean up subprocess and all connections. + + Ensures no orphaned subprocesses remain. Cleanup runs in a + ``finally``-like manner via the exit stack. + """ + # Clean up handler if we own it + if self._client_handler is not None and self._owns_handler: + with contextlib.suppress(Exception): + await self._client_handler.cleanup() + + if self._exit_stack is not None: + await self._exit_stack.aclose() + self._exit_stack = None + + self._process = None + self._reader = None + self._writer = None + self._connection = None + self._intercepted_methods.clear() + self._chain_initialized = False + self._conductor_initialized = False + + await super().__aexit__(exc_type, exc_val, exc_tb) + + # ------------------------------------------------------------------ + # Chain initialization (T9) + # ------------------------------------------------------------------ + + def _is_terminal(self, index: int) -> bool: + """Return True if the component at *index* is the terminal agent. + + The terminal agent is the last component in the chain. Since + ``_proxy_chain`` contains only proxies, any index equal to or + greater than its length refers to the terminal agent position. + + Args: + index: Zero-based chain position (0 = first proxy). + + Returns: + True if the index refers to the terminal agent. + """ + return index >= len(self._proxy_chain) + + async def _initialize_chain(self) -> None: + """Run the full proxy chain initialization sequence. + + Calls ``proxy/initialize`` ({attr:`PROXY_INITIALIZE`}) on each + proxy in order from client toward terminal agent, then calls + ``initialize`` on the terminal agent (last component). + + After initialization, the intercepted-methods lists from each + proxy are stored for use by message routing (T10). The + ``proxy/successor`` ({attr:`PROXY_SUCCESSOR`}) forwarding chain + is established implicitly by the list ordering: proxy *i*'s + successor is proxy *i+1*, and the last proxy's successor is the + terminal agent. + + !!! note "Zero-proxy case" + + When ``_proxy_chain`` is empty, this method skips proxy + initialization and connects directly to the terminal agent. + + Raises: + Exception: If any proxy or the terminal agent fails during + initialization. All started components are cleaned up + before re-raising. + """ + # Initialize each proxy in order (client → terminal). + for i, proxy in enumerate(self._proxy_chain): + try: + intercepted = await self._initialize_proxy(proxy, i) + except Exception: + # A proxy crashed during init — abort and clean up. + logger.exception( + "proxy_init_failed", + proxy_index=i, + method=PROXY_INITIALIZE, + ) + self._intercepted_methods.clear() + raise + self._intercepted_methods.append(intercepted) + + # Initialize the terminal agent (last component). + try: + await self._initialize_terminal() + except Exception: + # Terminal agent init failed — clean up proxy state. + logger.exception("terminal_init_failed") + self._intercepted_methods.clear() + raise + + self._chain_initialized = True + logger.info( + "chain_initialized", + proxy_count=len(self._proxy_chain), + forwarding_method=PROXY_SUCCESSOR, + ) + + async def _initialize_proxy(self, proxy: Proxy, index: int) -> list[str]: + """Initialize a single proxy and return its intercepted methods. + + Calls ``proxy_initialize()`` on the proxy, which returns the + list of ACP method names the proxy intercepts. These are stored + by the Conductor for passthrough optimization (T10): message + types not in any proxy's ``intercepted_methods`` are forwarded + without deserialization. + + Args: + proxy: The proxy to initialize. + index: Zero-based chain position (0 = closest to client). + + Returns: + List of intercepted ACP method names (e.g. + ``["session/prompt", "session/update"]``). + """ + logger.debug("proxy_init_start", proxy_index=index, method=PROXY_INITIALIZE) + return proxy.proxy_initialize() + + async def _initialize_terminal(self) -> None: + """Initialize the terminal agent (last component in the chain). + + Sends the standard ACP ``initialize`` method to the terminal + agent subprocess via the :class:`ClientSideConnection`. This is + NOT ``proxy/initialize`` — the terminal agent is a standard ACP + agent and does not know about proxy chains. + + Raises: + RuntimeError: If the connection has not been established. + """ + if self._connection is None: + raise RuntimeError( + "Cannot initialize terminal agent: connection not established", + ) + + from acp.agent.acp_agent_api import ACPAgentAPI + + api = ACPAgentAPI(self._connection) + await api.initialize( + title=self.name, + version="0.1.0", + name=self.name, + ) + + # ------------------------------------------------------------------ + # Message routing (T10) + # ------------------------------------------------------------------ + + def _should_intercept(self, method: str) -> bool: + """Check if any proxy in the chain intercepts the given method. + + Uses the ``intercepted_methods`` lists collected during + :meth:`_initialize_chain` to determine whether any proxy + declared interest in this method. When no proxy intercepts a + method, the Conductor can forward the raw message directly to + the terminal agent without deserialization (passthrough + optimization, design D5). + + Args: + method: JSON-RPC method name (e.g. ``"session/prompt"``). + + Returns: + True if at least one proxy intercepts this method. + """ + return any(method in intercepted for intercepted in self._intercepted_methods) + + async def _forward_through_proxies( + self, + method: str, + params: dict[str, Any], + meta: dict[str, Any], + ) -> dict[str, Any]: + """Forward a message through each proxy that intercepts the method. + + Iterates through the proxy chain in order (client → terminal). + For each proxy whose ``intercepted_methods`` list contains the + given *method*, calls ``proxy_successor()`` to let the proxy + inspect, modify, or block the message before forwarding. + + Proxies that do not intercept the method are skipped (they + would forward without deserialization anyway, so the + Conductor short-circuits them). + + If a proxy raises an exception, the error is propagated as a + JSON-RPC error response — never silently skipped. + + Args: + method: JSON-RPC method name. + params: Method parameters (may be modified by proxies). + meta: Additional metadata for routing (e.g. request ID, + session ID, chain position). + + Returns: + The response from the last intercepting proxy, or the + original *params* if no proxy intercepted the method. + """ + result: dict[str, Any] = params + for i, proxy in enumerate(self._proxy_chain): + if method not in self._intercepted_methods[i]: + continue + try: + result = await proxy.proxy_successor(method, result, meta) + except Exception as exc: + logger.exception( + "proxy_forward_failed", + proxy_index=i, + method=method, + ) + return await self._handle_proxy_error(exc, i) + return result + + async def _handle_proxy_error( + self, + error: Exception, + proxy_index: int, + ) -> dict[str, Any]: + """Produce a JSON-RPC error response for a proxy exception. + + Per the spec, proxy exceptions MUST produce a JSON-RPC error + response forwarded back through the chain. The Conductor SHALL + NOT silently skip failed proxies — a security hook proxy + failing silently is dangerous. + + If the exception is already a :class:`RequestError`, its code + and message are used directly. Otherwise, an internal error + code (-32603) is used with the exception message. + + Args: + error: The exception raised by the proxy. + proxy_index: Zero-based index of the failed proxy. + + Returns: + A dict with ``"error"`` key containing ``code``, + ``message``, and ``data`` fields following JSON-RPC 2.0 + error object format. + """ + if isinstance(error, RequestError): + error_obj: dict[str, Any] = { + "code": error.code, + "message": str(error), + "data": error.data, + } + else: + error_obj = { + "code": -32603, + "message": f"Proxy {proxy_index} error: {error}", + "data": { + "proxyIndex": proxy_index, + "errorType": type(error).__name__, + }, + } + logger.error( + "proxy_error_propagated", + proxy_index=proxy_index, + error_code=error_obj["code"], + error_message=error_obj["message"], + ) + return {"error": error_obj} + + async def _route_to_terminal( + self, + method: str, + params: dict[str, Any], + caller_meta: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Route a message through the proxy chain to the terminal agent. + + This is the core forwarding path for client→terminal messages. + It: + + 1. Builds routing metadata (chain position, method name). + 2. If any proxy intercepts *method*, forwards through each + intercepting proxy via :meth:`_forward_through_proxies`. + If a proxy returns an error response, propagation stops + immediately and the error is returned. + 3. Sends the (possibly modified) message to the terminal agent + via :class:`ClientSideConnection` using + ``send_request()``. + 4. Returns the terminal agent's response. + + For passthrough (no proxy intercepts *method*), the raw + message is sent directly to the terminal agent without any + proxy processing — the deserialization cost is zero (D5). + + Args: + method: JSON-RPC method name (e.g. ``"session/prompt"``). + params: Method parameters. + caller_meta: Optional metadata from caller (agent_name, prompt, etc.). + + Returns: + The response dict from the terminal agent, or an error + dict if a proxy blocked the message. + + Raises: + RuntimeError: If the connection has not been established. + """ + if self._connection is None: + raise RuntimeError( + "Cannot route message: connection not established", + ) + + # Merge caller-provided meta with routing metadata + meta: dict[str, Any] = { + "method": method, + "chain_length": len(self._proxy_chain), + } + if caller_meta is not None: + meta.update(caller_meta) + + # If any proxy intercepts this method, forward through + # the proxy chain first. Proxies may modify params or + # block the message entirely. + if self._should_intercept(method): + proxy_result = await self._forward_through_proxies( + method, + params, + meta, + ) + # If a proxy returned an error response, stop + # propagation — do not forward to terminal agent. + if "error" in proxy_result: + return proxy_result + params = proxy_result + + # Send to terminal agent via the wire connection. + response = await self._connection.send_request(method, params) + response_dict = response if isinstance(response, dict) else {"result": response} + + # Route response back through proxies in reverse order + # so post_turn hooks (HookProxy) and other response handlers fire. + if self._should_intercept(method): + meta["response"] = True + for i in reversed(range(len(self._proxy_chain))): + proxy = self._proxy_chain[i] + if i < len(self._intercepted_methods) and method in self._intercepted_methods[i]: + try: + response_dict = await proxy.proxy_successor(method, response_dict, meta) + except Exception as exc: + logger.exception( + "proxy_reverse_forward_failed", + proxy_index=i, + method=method, + ) + return await self._handle_proxy_error(exc, i) + + return response_dict + + async def _route_message( + self, + method: str, + params: dict[str, Any], + meta: dict[str, Any], + ) -> dict[str, Any]: + """Route a message bidirectionally through the proxy chain. + + This is the main entry point for message routing. It handles + both forward (client → terminal) and reverse (terminal → + client, i.e. response) routing. + + For forward routing, the message flows through each + intercepting proxy and then to the terminal agent. For + reverse routing (responses flowing back), the message flows + through intercepting proxies in reverse order. + + !!! note "Passthrough optimization" + + When no proxy intercepts *method*, the message is + forwarded directly to the terminal agent without + deserialization (design D5). + + Args: + method: JSON-RPC method name. + params: Method parameters. + meta: Additional metadata for routing (e.g. direction, + request ID for response correlation). + + Returns: + The response dict from the terminal agent or from + intercepting proxies. + """ + direction = meta.get("direction", "forward") + if direction == "forward": + return await self._route_to_terminal(method, params, meta) + # Reverse direction: responses flowing back from terminal + # agent through proxies to the client. Currently, responses + # are returned directly by _route_to_terminal. Full reverse + # proxy routing will be implemented when proxy response + # interception is needed (e.g. HookProxy post_turn). + if isinstance(params, dict): + return params + return {"result": params} + + # ------------------------------------------------------------------ + # MessageNode abstract methods (T11) + # ------------------------------------------------------------------ + + @override + async def get_stats(self) -> MessageStats | AggregatedMessageStats: + """Get message statistics for this node. + + Returns connection stats aggregated from all active Talk + connections. When the Conductor has no connections, returns an + empty :class:`MessageStats`. + + Returns: + Aggregated stats from all connections, or a fresh + :class:`MessageStats` if no connections exist. + """ + from agentpool.talk.stats import AggregatedMessageStats, MessageStats + + talks = self.connections.get_connections() + if not talks: + return MessageStats() + return AggregatedMessageStats(stats=[talk.stats for talk in talks]) + + @override + def run_iter(self, *prompts: Any, **kwargs: Any) -> AsyncIterator[ChatMessage[Any]]: + """Yield messages during sequential execution of multiple prompts. + + Each prompt is routed through the proxy chain to the terminal + agent, and the response is yielded as a :class:`ChatMessage`. + + Args: + *prompts: Input prompts to process sequentially. + **kwargs: Additional execution arguments. + + Yields: + Response :class:`ChatMessage` from the terminal agent for + each prompt, in order. + """ + return self._run_iter_impl(*prompts, **kwargs) + + async def _run_iter_impl( + self, + *prompts: Any, + **kwargs: Any, + ) -> AsyncIterator[ChatMessage[Any]]: + """Implementation of :meth:`run_iter`. + + Args: + *prompts: Input prompts to process sequentially. + **kwargs: Additional execution arguments. + + Yields: + Response :class:`ChatMessage` from the terminal agent for + each prompt. + """ + for prompt in prompts: + result = await self.run(prompt, **kwargs) + yield result + + @property + def _step(self) -> Step: + """Return a pydantic-graph Step wrapping the Conductor's execution. + + The Step's ``call`` function receives a :class:`StepContext` + containing :class:`AgentPoolState` with the input prompts. It + routes the prompt through the proxy chain via + :meth:`_route_message` and returns a :class:`ChatMessage[str]` + with the terminal agent's response. + + Returns: + A pydantic-graph :class:`Step` configured with the + Conductor's execution logic. + """ + from pydantic_graph import Step + from pydantic_graph.id_types import NodeID + + return Step( + id=NodeID(self.name), + call=self._execute_step, + label=f"Conductor({self.name})", + ) + + async def _execute_step(self, ctx: Any) -> ChatMessage[str]: + """Step function that routes a prompt through the proxy chain. + + Extracts the input prompt from the :class:`StepContext` state, + routes it through the proxy chain to the terminal agent via + :meth:`_route_message`, and returns the response as a + :class:`ChatMessage[str]`. + + Args: + ctx: pydantic-graph :class:`StepContext` containing + :class:`AgentPoolState` with prompts and kwargs. + + Returns: + A :class:`ChatMessage[str]` containing the terminal agent's + response. + + Raises: + RuntimeError: If the Conductor has not been initialized + (``__aenter__`` not called) or the connection is not + established. + """ + from agentpool.messaging import ChatMessage + + state: Any = ctx.state + prompts: tuple[Any, ...] = state.prompts + + if not self._conductor_initialized: + raise RuntimeError( + "Conductor must be entered via __aenter__ before execution", + ) + + if self._connection is None: + raise RuntimeError( + "Cannot execute step: connection not established", + ) + + # Build session/prompt params from the input prompts. + # The Conductor routes JSON-RPC messages; the first prompt + # is treated as the user's text input. + prompt_text: str = "" + if prompts: + first = prompts[0] + prompt_text = first if isinstance(first, str) else str(first) + + params: dict[str, Any] = { + "prompt": [{"type": "text", "text": prompt_text}], + } + + meta: dict[str, Any] = {"direction": "forward"} + response = await self._route_message("session/prompt", params, meta) + + # Extract text content from the response. + result_text: str = "" + if "result" in response: + result_val = response["result"] + if isinstance(result_val, str): + result_text = result_val + elif isinstance(result_val, dict): + result_text = str(result_val.get("text", result_val)) + else: + result_text = str(result_val) + elif "error" in response: + error_obj = response["error"] + result_text = f"Error: {error_obj.get('message', 'Unknown error')}" + + # Store the result on the state for run_stream() to pick up. + result_message: ChatMessage[str] = ChatMessage( + content=result_text, + role="assistant", + name=self.name, + ) + state.result = result_message + return result_message + + # ------------------------------------------------------------------ + # Utility + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + """Return a debug representation.""" + status = "initialized" if self._conductor_initialized else "not initialized" + return f"Conductor(name={self.name!r}, command={self._config.command!r}, {status})" diff --git a/src/acp/proxy/__init__.py b/src/acp/proxy/__init__.py new file mode 100644 index 000000000..a2cfbe7b8 --- /dev/null +++ b/src/acp/proxy/__init__.py @@ -0,0 +1,12 @@ +"""ACP proxy chain package.""" + +from acp.proxy.connection import ProxySideConnection +from acp.proxy.constants import PROXY_INITIALIZE, PROXY_SUCCESSOR +from acp.proxy.protocol import Proxy + +__all__ = [ + "PROXY_INITIALIZE", + "PROXY_SUCCESSOR", + "Proxy", + "ProxySideConnection", +] diff --git a/src/acp/proxy/connection.py b/src/acp/proxy/connection.py new file mode 100644 index 000000000..1c0114a99 --- /dev/null +++ b/src/acp/proxy/connection.py @@ -0,0 +1,82 @@ +"""Proxy-side connection wrapping a Connection for proxy chain dispatch.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + from acp.connection import Connection + from acp.proxy.protocol import Proxy + + +class ProxySideConnection: + """Wraps a Connection to dispatch proxy chain methods. + + Routes ``proxy/initialize`` and ``proxy/successor`` calls to a + :class:`Proxy` implementation, forwarding all other methods to the + wrapped :class:`Connection`. + """ + + def __init__(self, connection: Connection, proxy: Proxy) -> None: + self._connection = connection + self._proxy = proxy + + async def handle_proxy_method( + self, + method: str, + params: dict[str, Any], + ) -> dict[str, Any]: + """Dispatch a proxy chain method. + + Args: + method: The JSON-RPC method name. + params: The method parameters. + + Returns: + The response from the proxy handler. + + Raises: + ValueError: If the method is not a recognized proxy method. + """ + from acp.proxy.constants import PROXY_INITIALIZE, PROXY_SUCCESSOR + + if method == PROXY_INITIALIZE: + intercepted = self._proxy.proxy_initialize() + return {"intercepted_methods": intercepted} + if method == PROXY_SUCCESSOR: + meta: dict[str, Any] = params.pop("_meta", {}) if isinstance(params, dict) else {} + return await self._proxy.proxy_successor( + method=params.get("method", ""), params=params, meta=meta + ) + msg = f"Unknown proxy method: {method}" + raise ValueError(msg) + + async def send_request( + self, + method: str, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Send a request via the wrapped connection. + + Args: + method: The JSON-RPC method name. + params: Optional method parameters. + + Returns: + The response from the connection. + """ + result: dict[str, Any] = await self._connection.send_request(method, params or {}) + return result + + async def send_notification( + self, + method: str, + params: dict[str, Any] | None = None, + ) -> None: + """Send a notification via the wrapped connection.""" + await self._connection.send_notification(method, params or {}) + + async def close(self) -> None: + """Close the wrapped connection.""" + await self._connection.close() diff --git a/src/acp/proxy/constants.py b/src/acp/proxy/constants.py new file mode 100644 index 000000000..ad3e048f7 --- /dev/null +++ b/src/acp/proxy/constants.py @@ -0,0 +1,4 @@ +"""Wire method names for the ACP proxy chain protocol.""" + +PROXY_INITIALIZE = "proxy/initialize" +PROXY_SUCCESSOR = "proxy/successor" diff --git a/src/acp/proxy/impls/__init__.py b/src/acp/proxy/impls/__init__.py new file mode 100644 index 000000000..33ec66cf1 --- /dev/null +++ b/src/acp/proxy/impls/__init__.py @@ -0,0 +1,21 @@ +"""Proxy implementations package with type registry.""" + +from __future__ import annotations + +from acp.proxy.impls.base import ProxyRegistry, default_registry +from acp.proxy.impls.context_injection import ContextInjectionProxy +from acp.proxy.impls.hook_proxy import HookProxy +from acp.proxy.impls.tool_provider import ToolProviderProxy + +# Register built-in proxy types +default_registry.register("hook", HookProxy) +default_registry.register("context_injection", ContextInjectionProxy) +default_registry.register("tool_provider", ToolProviderProxy) + +__all__ = [ + "ContextInjectionProxy", + "HookProxy", + "ProxyRegistry", + "ToolProviderProxy", + "default_registry", +] diff --git a/src/acp/proxy/impls/base.py b/src/acp/proxy/impls/base.py new file mode 100644 index 000000000..c32678256 --- /dev/null +++ b/src/acp/proxy/impls/base.py @@ -0,0 +1,70 @@ +"""Proxy type registry for mapping string discriminators to proxy classes.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from acp.proxy.protocol import Proxy + + +class ProxyRegistry: + """Registry mapping string type discriminators to proxy classes.""" + + def __init__(self) -> None: + """Initialize an empty registry.""" + self._registry: dict[str, type[Proxy]] = {} + + def register(self, type_name: str, proxy_class: type[Proxy]) -> None: + """Register a proxy class under a type discriminator. + + Args: + type_name: The string discriminator (e.g., "hook", "context_injection"). + proxy_class: The class implementing the Proxy protocol. + + Raises: + ValueError: If the type_name is already registered. + """ + if type_name in self._registry: + msg = f"Proxy type '{type_name}' is already registered" + raise ValueError(msg) + self._registry[type_name] = proxy_class + + def get(self, type_name: str) -> type[Proxy]: + """Retrieve a proxy class by type discriminator. + + Args: + type_name: The string discriminator to look up. + + Returns: + The registered proxy class. + + Raises: + KeyError: If the type_name is not registered. + """ + if type_name not in self._registry: + msg = f"Unknown proxy type: '{type_name}'. Registered types: {self.registered_types()}" + raise KeyError(msg) + return self._registry[type_name] + + def is_registered(self, type_name: str) -> bool: + """Check if a type discriminator is registered.""" + return type_name in self._registry + + def registered_types(self) -> list[str]: + """Return a sorted list of all registered type names.""" + return sorted(self._registry.keys()) + + def __len__(self) -> int: + """Return the number of registered types.""" + return len(self._registry) + + def __contains__(self, type_name: object) -> bool: + """Check if a type name is registered.""" + if isinstance(type_name, str): + return type_name in self._registry + return False + + +default_registry = ProxyRegistry() diff --git a/src/acp/proxy/impls/context_injection.py b/src/acp/proxy/impls/context_injection.py new file mode 100644 index 000000000..2aa881f3e --- /dev/null +++ b/src/acp/proxy/impls/context_injection.py @@ -0,0 +1,118 @@ +"""ContextInjectionProxy — intercepts session/prompt to prepend context. + +Injects AGENTS.md content and skill instructions before the agent's prompt. +Must NOT conflate with HookProxy's additional_context. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from agentpool.log import get_logger + + +logger = get_logger(__name__) + + +class ContextInjectionProxy: + """Proxy that injects context (AGENTS.md, skills) into session/prompt. + + Implements the Proxy protocol defined in acp.proxy.protocol. + """ + + def __init__( + self, + agents_md_path: str | None = None, + skill_instructions: list[str] | None = None, + ) -> None: + """Initialize the ContextInjectionProxy. + + Args: + agents_md_path: Path to AGENTS.md file. If None, looks in cwd. + skill_instructions: List of skill instruction strings to inject. + """ + self._agents_md_path = agents_md_path + self._skill_instructions = skill_instructions or [] + + def proxy_initialize(self) -> list[str]: + """Return the list of ACP methods this proxy intercepts. + + Returns: + List of intercepted method names. + """ + return ["session/prompt"] + + async def proxy_successor( + self, + method: str, + params: dict[str, Any], + meta: dict[str, Any], + ) -> dict[str, Any]: + """Inject context into session/prompt requests. + + For session/prompt: prepends AGENTS.md and skill instructions. + For all other methods: passes through unchanged. + + Args: + method: The ACP method name. + params: The method parameters. + meta: Message metadata. + + Returns: + Modified params with injected context. + """ + if method != "session/prompt": + return params + + # Skip injection for responses + if meta.get("response", False): + return params + + context_parts: list[str] = [] + + # Inject AGENTS.md content + agents_content = self._read_agents_md() + if agents_content: + context_parts.append(agents_content) + + # Inject skill instructions + context_parts.extend(self._skill_instructions) + + if not context_parts: + return params + + # Prepend context to prompt content + # ACP protocol uses "prompt" key for session/prompt params + context_text = "\n\n".join(context_parts) + key = "prompt" if "prompt" in params else "content" + content_list: Any = params.get(key, []) + if isinstance(content_list, list): + content_list.insert(0, {"type": "text", "text": context_text}) + params[key] = content_list + elif isinstance(content_list, str): + params[key] = context_text + "\n\n" + content_list + + return params + + def _read_agents_md(self) -> str | None: + """Read AGENTS.md content from the configured or default path. + + Returns: + File content as string, or None if file not found. + """ + path_str = self._agents_md_path + if path_str is None: + # Default: look in current directory + path_str = "AGENTS.md" + + path = Path(path_str) + if not path.exists(): + logger.debug("AGENTS.md not found at %s, skipping injection", path) + return None + + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + logger.warning("Failed to read AGENTS.md at %s: %s", path, exc) + return None diff --git a/src/acp/proxy/impls/hook_proxy.py b/src/acp/proxy/impls/hook_proxy.py new file mode 100644 index 000000000..1c6240f71 --- /dev/null +++ b/src/acp/proxy/impls/hook_proxy.py @@ -0,0 +1,238 @@ +"""HookProxy — wraps existing AgentHooks instances as a Proxy in the ACP chain. + +Implements the Proxy protocol to intercept ACP messages and route them +through the agent's hook system. Maps all 4 hook types to ACP message flows. + +Hook type mappings: +- session/prompt (request) → pre_turn (deny blocks, additional_context injected) +- session/update ToolCallStart → pre_tool_use (deny blocks, modified_input) +- session/update ToolCallComplete → post_tool_use (modified_output) +- session/prompt (response) → post_turn (correlated by request ID, NOT on chunks) +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from agentpool.log import get_logger + + +if TYPE_CHECKING: + from agentpool.hooks.agent_hooks import AgentHooks + +logger = get_logger(__name__) + + +class HookProxy: + """Proxy that wraps AgentHooks instances and routes ACP messages through hooks. + + Implements the Proxy protocol defined in acp.proxy.protocol. + """ + + def __init__(self, hooks: list[AgentHooks]) -> None: + """Initialize the HookProxy with a list of AgentHooks. + + Args: + hooks: List of AgentHooks instances to wrap. + """ + self._hooks = hooks + + def proxy_initialize(self) -> list[str]: + """Return the list of ACP methods this proxy intercepts. + + Returns: + List of intercepted method names. + """ + return ["session/prompt", "session/update"] + + async def proxy_successor( + self, + method: str, + params: dict[str, Any], + meta: dict[str, Any], + ) -> dict[str, Any]: + """Route an ACP message through the appropriate hook handler. + + Args: + method: The ACP method name (e.g., "session/prompt"). + params: The method parameters. + meta: Metadata about the message (may contain "response", "direction"). + + Returns: + The (possibly modified) response dict. + """ + is_response = meta.get("response", False) + + if method == "session/prompt": + if is_response: + return await self._handle_post_turn(params, meta) + return await self._handle_pre_turn(params, meta) + + if method == "session/update": + return await self._handle_session_update(params, meta) + + # Passthrough for unintercepted methods + return params + + async def _handle_pre_turn( + self, + params: dict[str, Any], + meta: dict[str, Any], + ) -> dict[str, Any]: + """Handle session/prompt request → pre_turn hooks. + + Args: + params: The prompt parameters. + meta: Message metadata. + + Returns: + Modified params (additional_context injected) or error response. + """ + agent_name: str = meta.get("agent_name", "") + prompt: str = "" + # ACP protocol uses "prompt" key for session/prompt params + key = "prompt" if "prompt" in params else "content" + content: Any = params.get(key, []) + if isinstance(content, str): + prompt = content + elif isinstance(content, list) and content: + first = content[0] + if isinstance(first, dict): + prompt = first.get("text", "") + + for hook in self._hooks: + result = await hook.run_pre_turn_hooks( + agent_name=agent_name, + prompt=prompt, + ) + if result.get("decision") == "deny": + return { + "error": { + "code": -32603, + "message": "Blocked by pre_turn hook", + "data": {"reason": result.get("reason", "")}, + } + } + additional_context = result.get("additional_context") + if additional_context: + content_list: list[Any] = params.get(key, []) + if isinstance(content_list, list): + content_list.insert( + 0, + {"type": "text", "text": additional_context}, + ) + params[key] = content_list + return params + + async def _handle_post_turn( + self, + params: dict[str, Any], + meta: dict[str, Any], + ) -> dict[str, Any]: + """Handle session/prompt response → post_turn hooks. + + Must NOT fire on individual AgentMessageChunk chunks. + Only fires when the full JSON-RPC response is received. + + Args: + params: The response parameters. + meta: Message metadata (must contain "response": True). + + Returns: + Modified response (modified_output applied). + """ + agent_name: str = meta.get("agent_name", "") + prompt: str = meta.get("prompt", "") + + for hook in self._hooks: + result = await hook.run_post_turn_hooks( + agent_name=agent_name, + prompt=prompt, + result=params, + duration_ms=meta.get("duration_ms", 0.0), + ) + modified_output = result.get("modified_output") + if modified_output is not None and isinstance(modified_output, dict): + params = modified_output + return params + + async def _handle_session_update( + self, + params: dict[str, Any], + meta: dict[str, Any], + ) -> dict[str, Any]: + """Handle session/update → pre_tool_use / post_tool_use hooks. + + Routes based on update type: + - ToolCallStart → pre_tool_use + - ToolCallComplete → post_tool_use + - AgentMessageChunk → passthrough (no hook firing) + + Args: + params: The update parameters. + meta: Message metadata. + + Returns: + Modified params or error response. + """ + update: Any = params.get("update", params) + update_type: str = "" + if isinstance(update, dict): + update_type = update.get("type", "") + + # ToolCallStart → pre_tool_use + if "ToolCallStart" in update_type or update_type == "tool_call_start": + tool_name: str = "" + tool_input: dict[str, Any] = {} + if isinstance(update, dict): + tool_name = update.get("tool_call_id", "") + raw_input = update.get("raw_input", {}) + if isinstance(raw_input, dict): + tool_input = raw_input + agent_name: str = meta.get("agent_name", "") + for hook in self._hooks: + result = await hook.run_pre_tool_hooks( + agent_name=agent_name, + tool_name=tool_name, + tool_input=tool_input, + ) + if result.get("decision") == "deny": + return { + "error": { + "code": -32603, + "message": f"Blocked by pre_tool_use hook for {tool_name}", + "data": {"reason": result.get("reason", "")}, + } + } + modified_input = result.get("modified_input") + if modified_input is not None and isinstance(update, dict): + update["raw_input"] = modified_input + return params + + # ToolCallComplete → post_tool_use + if "ToolCallComplete" in update_type or update_type == "tool_call_complete": + tc_tool_name = "" + tc_tool_input: dict[str, Any] = {} + tc_tool_output: Any = None + if isinstance(update, dict): + tc_tool_name = update.get("tool_call_id", "") + raw_input = update.get("raw_input", {}) + if isinstance(raw_input, dict): + tc_tool_input = raw_input + tc_tool_output = update.get("raw_output") + agent_name = meta.get("agent_name", "") + for hook in self._hooks: + result = await hook.run_post_tool_hooks( + agent_name=agent_name, + tool_name=tc_tool_name, + tool_input=tc_tool_input, + tool_output=tc_tool_output, + duration_ms=meta.get("duration_ms", 0.0), + ) + modified_output = result.get("modified_output") + if modified_output is not None and isinstance(update, dict): + update["raw_output"] = modified_output + return params + + # AgentMessageChunk and other updates → passthrough (no hook firing) + return params diff --git a/src/acp/proxy/impls/tool_provider.py b/src/acp/proxy/impls/tool_provider.py new file mode 100644 index 000000000..09c157561 --- /dev/null +++ b/src/acp/proxy/impls/tool_provider.py @@ -0,0 +1,58 @@ +"""ToolProviderProxy — experimental proxy for MCP-over-ACP tool sharing. + +This is an experimental implementation that will eventually use +AcpMcpTransport/AcpMcpConnectionManager to share tools across ACP agents. +Currently a stub that passes messages through unchanged. +""" + +from __future__ import annotations + +from typing import Any + +from agentpool.log import get_logger + + +logger = get_logger(__name__) + + +class ToolProviderProxy: + """Experimental proxy for providing tools via MCP-over-ACP. + + Implements the Proxy protocol defined in acp.proxy.protocol. + + NOTE: This is experimental. Future implementation will use + AcpMcpTransport and AcpMcpConnectionManager for real MCP tool sharing. + """ + + def __init__(self) -> None: + """Initialize the ToolProviderProxy.""" + logger.debug("ToolProviderProxy initialized (experimental)") + + def proxy_initialize(self) -> list[str]: + """Return the list of ACP methods this proxy intercepts. + + Returns: + List of intercepted method names. + """ + return ["session/prompt"] + + async def proxy_successor( + self, + method: str, + params: dict[str, Any], + meta: dict[str, Any], + ) -> dict[str, Any]: + """Pass through messages unchanged (experimental stub). + + Future implementation will intercept session/prompt to advertise + available MCP tools to the terminal agent. + + Args: + method: The ACP method name. + params: The method parameters. + meta: Message metadata. + + Returns: + Unchanged params (passthrough). + """ + return params diff --git a/src/acp/proxy/protocol.py b/src/acp/proxy/protocol.py new file mode 100644 index 000000000..34c6b987c --- /dev/null +++ b/src/acp/proxy/protocol.py @@ -0,0 +1,45 @@ +"""Proxy protocol for ACP proxy chain.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + + +if TYPE_CHECKING: + from collections.abc import Awaitable + + +@runtime_checkable +class Proxy(Protocol): + """Protocol for ACP proxy chain components. + + A proxy intercepts messages between the client and terminal agent. + Each proxy declares which methods it intercepts and handles + successor message forwarding. + """ + + def proxy_initialize(self) -> list[str]: + """Initialize the proxy and return intercepted method names. + + Returns: + List of method names this proxy intercepts. + """ + ... + + def proxy_successor( + self, + method: str, + params: dict[str, Any], + meta: dict[str, Any], + ) -> Awaitable[dict[str, Any]]: + """Forward a successor message to the next component in the chain. + + Args: + method: The JSON-RPC method name. + params: The method parameters. + meta: Additional metadata for routing. + + Returns: + An awaitable resolving to the response from the successor. + """ + ... diff --git a/src/agentpool/agents/acp_agent/acp_agent.py b/src/agentpool/agents/acp_agent/acp_agent.py index e5b6bef11..5aa6a2742 100644 --- a/src/agentpool/agents/acp_agent/acp_agent.py +++ b/src/agentpool/agents/acp_agent/acp_agent.py @@ -30,46 +30,34 @@ import asyncio import contextlib -from dataclasses import replace from datetime import datetime import os from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Self, cast +from typing import TYPE_CHECKING, Any, ClassVar, Self import uuid import anyio from pydantic import HttpUrl from pydantic_ai import ( - ModelRequest, - ModelResponse, - TextPart, - ToolReturnPart, UserContent, - UserPromptPart, ) from acp import InitializeRequest from acp.agent import ACPAgentAPI -from agentpool.agents.acp_agent.session_state import ACPSessionState +from agentpool.agents.acp_agent.adapter import ACPClientAdapter +from agentpool.agents.acp_agent.session_state import ACPState from agentpool.agents.acp_agent.turn import ACPTurn from agentpool.agents.base_agent import BaseAgent from agentpool.agents.events import ( RunStartedEvent, - StreamCompleteEvent, - ToolCallCompleteEvent, - ToolResultMetadataEvent, ) -from agentpool.agents.events.processors import event_to_part from agentpool.agents.exceptions import ( AgentNotInitializedError, UnknownCategoryError, UnknownModeError, ) from agentpool.log import get_logger -from agentpool.messaging import ChatMessage -from agentpool.orchestrator.core import EventEnvelope from agentpool.utils.subprocess_utils import SubprocessError, run_with_process_monitor -from agentpool.utils.token_breakdown import calculate_usage_from_parts if TYPE_CHECKING: @@ -79,28 +67,29 @@ from anyio.abc import Process from evented_config import EventConfig from exxec import ExecutionEnvironment - from pydantic_ai import ThinkingPart, ToolCallPart, UserContent + from pydantic_ai import UserContent from pydantic_ai.messages import ModelMessage from slashed import BaseCommand from tokonomics.model_discovery.model_info import ModelInfo from acp.client.connection import ClientSideConnection + from acp.conductor import Conductor from acp.schema import Implementation, RequestPermissionRequest, RequestPermissionResponse from acp.schema.capabilities import AgentCapabilities from acp.schema.mcp import McpServer from agentpool.agents.acp_agent.client_handler import ACPClientHandler - from agentpool.agents.acp_agent.turn import ACPClientProtocol from agentpool.agents.context import AgentRunContext from agentpool.agents.events import RichAgentStreamEvent from agentpool.agents.modes import ModeCategory from agentpool.common_types import AnyEventHandlerType from agentpool.delegation import AgentPool from agentpool.hooks import AgentHooks - from agentpool.messaging import MessageHistory + from agentpool.mcp_server import ToolBridge + from agentpool.messaging import ChatMessage, MessageHistory from agentpool.models.acp_agents import BaseACPAgentConfig from agentpool.orchestrator.turn import Turn - from agentpool.resource_providers import ResourceProvider from agentpool.sessions import SessionData + from agentpool.tools.factory import ToolsetFactory from agentpool.ui.base import InputProvider from agentpool_config.mcp_server import MCPServerConfig @@ -145,7 +134,7 @@ def __init__( # ACP initialization init_request: InitializeRequest | None = None, # Tools - tool_providers: list[ResourceProvider] | None = None, + tool_factories: list[ToolsetFactory] | None = None, mcp_servers: Sequence[str | MCPServerConfig] | None = None, # Runtime options deps_type: type[TDeps] | None = None, @@ -158,9 +147,9 @@ def __init__( commands: Sequence[BaseCommand] | None = None, hooks: AgentHooks | None = None, session_id: str | None = None, + # Conductor + proxy_chain: list[Any] | None = None, ) -> None: - from agentpool.mcp_server.tool_bridge import ToolManagerBridge - super().__init__( name=name or command, description=description, @@ -188,7 +177,8 @@ def __init__( # ACP initialization self._init_request = init_request or InitializeRequest.create_for_package("agentpool") # Tools - self._tool_providers = tool_providers or [] + self._tool_factories = tool_factories or [] + self._extra_toolsets: list[Any] = [] # Provider type for model messages self._provider_type = provider_type # ACP-specific state @@ -202,13 +192,17 @@ def __init__( self._agent_info: Implementation | None = None self._caps: AgentCapabilities | None = None self._sdk_session_id: str | None = session_id - self._state: ACPSessionState | None = None + self._state: ACPState | None = None self._extra_mcp_servers: list[McpServer] = [] self._sessions_cache: list[SessionData] | None = None - # ToolManagerBridge gets injection_manager from node's run context - self._tool_bridge = ToolManagerBridge(node=self) + # ToolBridge lazily created in _setup_toolsets() when tools exist + self._tool_bridge: ToolBridge | None = None # Track the prompt task for cancellation self._prompt_task: asyncio.Task[Any] | None = None + # Conductor + self._proxy_chain = proxy_chain + self._conductor: Conductor | None = None + self._init_response: Any = None @classmethod def from_config( @@ -244,7 +238,7 @@ def from_config( allow_file_operations=config.allow_file_operations, ), # Tools - tool_providers=config.get_tool_providers(), + tool_factories=config.get_tool_factories(), mcp_servers=config.mcp_servers, # Runtime options event_handlers=merged_handlers or None, @@ -254,6 +248,8 @@ def from_config( deps_type=deps_type, auto_approve=config.auto_approve, hooks=config.hooks.get_agent_hooks() if config.hooks else None, + # Conductor + proxy_chain=config.proxy_chain, ) @property @@ -268,47 +264,137 @@ def client_env(self) -> ExecutionEnvironment: async def _setup_toolsets(self) -> None: """Initialize toolsets and start bridge if needed.""" from acp.schema import HttpMcpServer + from agentpool.mcp_server import create_tool_bridge + from agentpool.tools.base import Tool + from agentpool.tools.factory import StaticToolsetFactory + + if not self._tool_factories: + return + + all_tools: list[Any] = [] + self._extra_toolsets = [] + + for factory in self._tool_factories: + match factory: + case StaticToolsetFactory(tools=factory_tools): + all_tools.extend(factory_tools) + case _: + cap = await factory.create_capability() + if cap is not None: + self._extra_toolsets.append(cap) - if not self._tool_providers: + if not all_tools: return - # Add all tool providers to tool manager - for provider in self._tool_providers: - self.tools.add_provider(provider) + + # Register tools with the node's tool manager for bridge discovery + for tool in all_tools: + if isinstance(tool, Tool): + self.tools.register_tool(tool) + + # Lazily create and start the tool bridge + self._tool_bridge = create_tool_bridge(node=self) await self._tool_bridge.start() url = HttpUrl(self._tool_bridge.url) mcp_config = HttpMcpServer(name=self._tool_bridge.resolved_server_name, url=url) self._extra_mcp_servers.append(mcp_config) + async def _setup_conductor(self) -> None: + """Set up Conductor for proxy chain execution. + + Creates ACPState + ACPClientHandler BEFORE entering Conductor + (solves chicken-and-egg: Conductor needs the handler to wire + notifications). Then enters Conductor, which spawns the subprocess + and creates the proxy-chained connection. Finally wires ACPAgent's + connection/api to Conductor's connection. + """ + from acp.conductor import Conductor + + # Create ACPState + ACPClientHandler before Conductor enters + # (Conductor wires the handler to ClientSideConnection) + if self._state is None: + self._state = ACPState(session_id="") + if self._client_handler is None: + from agentpool.agents.acp_agent.client_handler import ACPClientHandler + + self._client_handler = ACPClientHandler(self, self._state, self._input_provider) + + self._conductor = Conductor( + name=self.name, + command=self._command, + args=self._args, + cwd=self._cwd, + env=dict(self._env_vars), + proxy_chain=self._proxy_chain or [], + client_handler=self._client_handler, + agent_hooks=self.hooks if self.hooks else None, + ) + await self._conductor.__aenter__() + + # Wire ACPAgent's connection/api to Conductor's connection + # so that ACPTurn and ACPClientAdapter use the proxy-chained connection. + if self._conductor.connection is not None: + self._connection = self._conductor.connection + from acp.agent.acp_agent_api import ACPAgentAPI + + self._api = ACPAgentAPI(self._connection) + async def __aenter__(self) -> Self: """Start subprocess and initialize ACP connection.""" await super().__aenter__() await self._setup_toolsets() - process = await self._start_process() - try: - await run_with_process_monitor(process, self._initialize, context="ACP initialization") - # Load existing session or create new one - if session_to_load := self._sdk_session_id: - self._sdk_session_id = None - result = await run_with_process_monitor( - process, - lambda: self.load_session(session_to_load), - context="ACP session load", + + if self._proxy_chain: + # Proxy chain mode: Conductor manages subprocess + proxy chain. + # ACPAgent wires its connection/api to Conductor's connection. + await self._setup_conductor() + # Initialize and create session using Conductor's connection. + assert self._conductor is not None + assert self._conductor.process is not None + self._process = self._conductor.process + process = self._process + try: + await run_with_process_monitor( + process, self._initialize, context="ACP initialization" + ) + await run_with_process_monitor( + process, self._create_session, context="ACP session creation" + ) + except SubprocessError as e: + await self._cleanup() + raise RuntimeError(str(e)) from e + except Exception: + await self._cleanup() + raise + else: + # Direct mode: ACPAgent manages its own subprocess. + process = await self._start_process() + try: + await run_with_process_monitor( + process, self._initialize, context="ACP initialization" ) - if result is None: - self.log.warning( - "Failed to load session, creating new one", - session_id=session_to_load, + # Load existing session or create new one + if session_to_load := self._sdk_session_id: + self._sdk_session_id = None + result = await run_with_process_monitor( + process, + lambda: self.load_session(session_to_load), + context="ACP session load", ) + if result is None: + self.log.warning( + "Failed to load session, creating new one", + session_id=session_to_load, + ) + await run_with_process_monitor( + process, self._create_session, context="ACP session creation" + ) + else: await run_with_process_monitor( process, self._create_session, context="ACP session creation" ) - else: - await run_with_process_monitor( - process, self._create_session, context="ACP session creation" - ) - except SubprocessError as e: - raise RuntimeError(str(e)) from e + except SubprocessError as e: + raise RuntimeError(str(e)) from e await anyio.sleep(0.3) return self @@ -335,22 +421,42 @@ async def _start_process(self) -> Process: return self._process async def _initialize(self) -> None: - """Initialize the ACP connection.""" + """Initialize the ACP connection. + + In conductor mode, the connection is already created by Conductor. + We only need to create ACPState, ACPClientHandler (if not done), + and call initialize on the existing connection. + + In direct mode, creates a new ClientSideConnection from the + subprocess stdin/stdout. + """ from acp.client.connection import ClientSideConnection from agentpool.agents.acp_agent.client_handler import ACPClientHandler if not self._process or not self._process.stdin or not self._process.stdout: raise RuntimeError("Process not started") - self._state = ACPSessionState(session_id="") - self._client_handler = ACPClientHandler(self, self._state, self._input_provider) - self._connection = ClientSideConnection( - to_client=self._client_handler, - input_stream=self._process.stdin, - output_stream=self._process.stdout, - ) - self._api = ACPAgentAPI(self._connection) + # Create ACPState if not already created + if self._state is None: + self._state = ACPState(session_id="") + + # Create ACPClientHandler if not already created (conductor mode + # creates it before entering Conductor) + if self._client_handler is None: + self._client_handler = ACPClientHandler(self, self._state, self._input_provider) + + # Only create new connection if not already set by Conductor + if self._connection is None: + self._connection = ClientSideConnection( + to_client=self._client_handler, + input_stream=self._process.stdin, + output_stream=self._process.stdout, + ) + self._api = ACPAgentAPI(self._connection) + + # Initialize the ACP connection (sends initialize request) init_response = await self._connection.initialize(self._init_request) + self._init_response = init_response self._agent_info = init_response.agent_info self._caps = init_response.agent_capabilities self.log.info("ACP connection initialized", agent_info=self._agent_info) @@ -384,8 +490,13 @@ async def _create_session(self) -> None: async def _cleanup(self) -> None: """Clean up resources.""" - if self._tool_bridge._mcp is not None: + if self._conductor is not None: + await self._conductor.__aexit__(None, None, None) + self._conductor = None + if self._tool_bridge is not None: await self._tool_bridge.stop() + self._tool_bridge = None + self._extra_toolsets.clear() self._extra_mcp_servers.clear() if self._client_handler: await self._client_handler.cleanup() @@ -409,7 +520,7 @@ async def _cleanup(self) -> None: self.log.exception("Error terminating ACP process") self._process = None - async def _stream_events( # noqa: PLR0915 + async def _stream_events( self, run_ctx: AgentRunContext, prompts: list[UserContent], @@ -426,33 +537,18 @@ async def _stream_events( # noqa: PLR0915 wait_for_connections: bool | None = None, store_history: bool = True, ) -> AsyncIterator[RichAgentStreamEvent[str]]: - from agentpool.agents.acp_agent.acp_converters import ( - convert_to_acp_content, - to_finish_reason, - ) + """Stream events by delegating to ACPTurn.execute() via create_turn(). - # Update input provider if provided + This is a thin wrapper preserved for backward compatibility. + The actual execution logic lives in ACPTurn.execute(). + """ if input_provider is not None and self._client_handler: self._client_handler._input_provider = input_provider if not self._api or not self._sdk_session_id or not self._state: raise AgentNotInitializedError - run_id = str(uuid.uuid4()) - self._state.clear() - model_messages: list[ModelResponse | ModelRequest] = [] - initial_request = ModelRequest(parts=[UserPromptPart(content=prompts)]) - model_messages.append(initial_request) - current_response_parts: list[TextPart | ThinkingPart | ToolCallPart] = [] - text_chunks: list[str] = [] - assert session_id is not None - yield RunStartedEvent( - session_id=session_id, - run_id=run_id, - agent_name=self.name, - parent_session_id=parent_session_id, - ) - final_blocks = convert_to_acp_content(prompts) + # Handle ephemeral execution (fork session if store_history=False) acp_session_id = self._sdk_session_id if not store_history and self._sdk_session_id: @@ -460,155 +556,31 @@ async def _stream_events( # noqa: PLR0915 fork_response = await self._api.fork_session(self._sdk_session_id, cwd) acp_session_id = fork_response.session_id self.log.debug("Forked session", parent=self._sdk_session_id, fork=acp_session_id) - self.log.debug("Starting streaming prompt", num_blocks=len(final_blocks)) - prompt_task = asyncio.create_task(self._api.prompt(acp_session_id, final_blocks)) - self._prompt_task = prompt_task - - async def poll_acp_events() -> AsyncIterator[RichAgentStreamEvent[str]]: - """Poll raw updates from ACP state, convert to events, until prompt completes.""" - from agentpool.agents.acp_agent.acp_converters import acp_to_native_event - - assert self._state - while not prompt_task.done(): - if self._client_handler: - try: - await self._client_handler._update_event.wait_with_timeout(0.05) - self._client_handler._update_event.clear() - except TimeoutError: - pass - while (update := self._state.pop_update()) is not None: - if native_event := acp_to_native_event(update): - yield native_event - while (update := self._state.pop_update()) is not None: - if native_event := acp_to_native_event(update): - yield native_event - - tool_metadata: dict[str, dict[str, Any]] = {} - - try: - agent_ctx = self.get_context(run_ctx=run_ctx, input_provider=input_provider) - async with self._tool_bridge.set_run_context(agent_ctx, prompt=prompts): - send_stream, receive_stream = anyio.create_memory_object_stream( - max_buffer_size=1000 - ) - - async def _forward_acp_events() -> None: - try: - async for event in poll_acp_events(): - try: - await send_stream.send(event) - except (anyio.ClosedResourceError, anyio.BrokenResourceError): - return - finally: - await send_stream.aclose() - - # Do NOT subscribe to run_ctx.event_bus here: in standalone mode - # the producer publishes _stream_events() output back into the - # same local EventBus, creating a self-echo infinite loop. - _bg_tasks: set[asyncio.Task[Any]] = set() - task_a = asyncio.create_task(_forward_acp_events()) - _bg_tasks.add(task_a) - task_a.add_done_callback(_bg_tasks.discard) - - try: - async for raw_event in receive_stream: - event = ( - raw_event.event if isinstance(raw_event, EventEnvelope) else raw_event - ) - if isinstance(event, ToolResultMetadataEvent): - tool_metadata[event.tool_call_id] = event.metadata - continue - if run_ctx.cancelled: - self.log.info("Stream cancelled by user") - break - if isinstance(event, ToolCallCompleteEvent): - enriched_event = event - if not enriched_event.agent_name: - enriched_event = replace(enriched_event, agent_name=self.name) - if ( - enriched_event.metadata is None - and enriched_event.tool_call_id in tool_metadata - ): - enriched_event = replace( - enriched_event, - metadata=tool_metadata[enriched_event.tool_call_id], - ) - output_event = enriched_event - else: - output_event = event - part = event_to_part(output_event) - if isinstance(part, TextPart): - text_chunks.append(part.content) - if part and not isinstance(part, ToolReturnPart): - current_response_parts.append(part) - yield output_event - finally: - for t in list(_bg_tasks): - t.cancel() - for t in list(_bg_tasks): - try: - await t - except asyncio.CancelledError: - pass - except Exception: - self.log.exception("Error during background task cleanup") - except asyncio.CancelledError: - self.log.info("Stream cancelled via task cancellation") - run_ctx.cancelled = True - if run_ctx.cancelled: - message = ChatMessage[str]( - content="".join(text_chunks), - role="assistant", - name=self.name, - message_id=message_id or str(uuid.uuid4()), - session_id=session_id, - parent_id=user_msg.message_id, - model_name=self.model_name, - messages=model_messages, - metadata={}, - finish_reason="stop", - ) - yield StreamCompleteEvent(message=message) - self._prompt_task = None - return - - response = await prompt_task - finish_reason = to_finish_reason(response.stop_reason) - if current_response_parts: - model_messages.append( - ModelResponse( - parts=current_response_parts, - finish_reason=finish_reason, - model_name=self.model_name, - provider_name=self._provider_type, - ) - ) - - text_content = "".join(text_chunks) - usage, cost_info = await calculate_usage_from_parts( - input_parts=prompts, - response_parts=current_response_parts, - text_content=text_content, - model_name=self.model_name, - provider=self._provider_type, + # Delegate to ACPTurn.execute() via create_turn() + assert self._api is not None + assert self._client_handler is not None + turn = self.create_turn( + prompts=prompts, + run_ctx=run_ctx, + message_history=message_history, # type: ignore[arg-type] ) - message = ChatMessage[str]( - content=text_content, - role="assistant", - name=self.name, - message_id=message_id or str(uuid.uuid4()), + run_id = str(uuid.uuid4()) + yield RunStartedEvent( session_id=session_id, - parent_id=user_msg.message_id, - model_name=self.model_name, - messages=model_messages, - metadata={}, - finish_reason=finish_reason, - usage=usage, - cost_info=cost_info, + run_id=run_id, + agent_name=self.name, + parent_session_id=parent_session_id, ) - yield StreamCompleteEvent(message=message) + + async for event in turn.execute(): + yield event + + if turn._final_message is not None: + self._final_message = turn._final_message + if turn._message_history: + self._message_history = turn._message_history @property def model_name(self) -> str | None: @@ -645,14 +617,12 @@ def create_turn( Returns: An ACPTurn instance for single-cycle execution. """ - # TODO: ACPAgentAPI does not implement ACPClientProtocol fully — - # it lacks stream_events() and get_messages(). At runtime this will raise - # AttributeError when ACPTurn.execute() calls those methods. An adapter - # wrapping ACPAgentAPI with async futures / notification registry is needed - # for full integration. + assert self._api is not None + assert self._client_handler is not None + str_prompts: list[str] = [str(p) if not isinstance(p, str) else p for p in prompts] return ACPTurn( - acp_client=cast("ACPClientProtocol", self._api), - prompts=prompts, # type: ignore[arg-type] + acp_client=ACPClientAdapter(self._api, self._client_handler, conductor=self._conductor), + prompts=str_prompts, run_ctx=run_ctx, message_history=message_history, session_id=self._sdk_session_id or run_ctx.session_id, @@ -662,7 +632,7 @@ def create_turn( ) async def _interrupt(self, run_ctx: AgentRunContext | None = None) -> None: - """Send CancelNotification to remote ACP server and cancel local tasks. + """Send CancelNotification to remote ACP server and mark run as cancelled. Args: run_ctx: Optional per-run context for the stream to interrupt @@ -673,10 +643,9 @@ async def _interrupt(self, run_ctx: AgentRunContext | None = None) -> None: self.log.info("Sent cancel notification to ACP server") except Exception: self.log.exception("Failed to send cancel notification to ACP server") - - if self._prompt_task and not self._prompt_task.done(): - self._prompt_task.cancel() - self.log.info("Cancelled prompt task") + if run_ctx is not None: + run_ctx.cancelled = True + self.log.info("Marked run as cancelled") async def get_available_models(self) -> list[ModelInfo] | None: """Get available models from the ACP session state.""" diff --git a/src/agentpool/agents/acp_agent/acp_converters.py b/src/agentpool/agents/acp_agent/acp_converters.py index b3be6a8c8..27facab95 100644 --- a/src/agentpool/agents/acp_agent/acp_converters.py +++ b/src/agentpool/agents/acp_agent/acp_converters.py @@ -186,8 +186,10 @@ def get_modes( return categories -def to_finish_reason(stop_reason: StopReason) -> FinishReason: - return STOP_REASON_MAP.get(stop_reason, "stop") +def to_finish_reason(stop_reason: str | None) -> FinishReason: + if stop_reason is None: + return "stop" + return STOP_REASON_MAP.get(stop_reason, "stop") # type: ignore[call-overload,no-any-return] def convert_acp_locations( diff --git a/src/agentpool/agents/acp_agent/adapter.py b/src/agentpool/agents/acp_agent/adapter.py new file mode 100644 index 000000000..fad4f26a9 --- /dev/null +++ b/src/agentpool/agents/acp_agent/adapter.py @@ -0,0 +1,254 @@ +"""ACPClientAdapter — bridges ACPAgentAPI to the ACPClientProtocol interface. + +This adapter makes :meth:`ACPAgentAPI.prompt` non-blocking by launching it +as a background asyncio task and routing session-update notifications to an +async queue that :meth:`stream_events` consumes. + +When a Conductor is provided, prompt() routes through the proxy chain +via ``conductor._route_to_terminal()`` instead of ``api.prompt()`` +directly, ensuring proxies (HookProxy, ContextInjectionProxy, etc.) +intercept and process messages. + +Used by :class:`~agentpool.agents.acp_agent.turn.ACPTurn` via the +:class:`~agentpool.agents.acp_agent.turn.ACPClientProtocol` interface. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from acp.agent.acp_agent_api import ACPAgentAPI + from acp.schema import ContentBlock, PromptResponse, SessionUpdate + from agentpool.agents.acp_agent.client_handler import ACPClientHandler + + +class ACPClientAdapter: + """Adapter wrapping :class:`ACPAgentAPI` for non-blocking ACP turn execution. + + Bridges the blocking ``ACPAgentAPI.prompt()`` (which returns + ``PromptResponse`` only after all notifications) to the + :class:`~agentpool.agents.acp_agent.turn.ACPClientProtocol` interface + expected by :class:`~agentpool.agents.acp_agent.turn.ACPTurn`. + + When a Conductor is provided, the adapter routes ``session/prompt`` + through the proxy chain instead of calling ``api.prompt()`` directly. + This ensures all configured proxies (HookProxy, ContextInjectionProxy, + ToolProviderProxy) intercept and process messages. + + The adapter: + - Fires prompt as a background task (fire-and-forget) + - Routes session-update notifications to an async queue + - Exposes ``stop_reason`` after the background task completes + """ + + def __init__( + self, + api: ACPAgentAPI, + notification_source: ACPClientHandler | asyncio.Queue[SessionUpdate], + conductor: Any | None = None, + ) -> None: + """Initialize the adapter. + + Args: + api: The ACP agent API for sending prompts and retrieving messages. + notification_source: Either an :class:`ACPClientHandler` that + collects session updates or a raw ``asyncio.Queue`` of + :class:`SessionUpdate` items. + conductor: Optional Conductor for proxy chain routing. When + provided and the method is intercepted, ``prompt()`` routes + through ``conductor._route_to_terminal()`` instead of + ``api.prompt()`` directly. + """ + self._api = api + self._notification_source = notification_source + self._conductor = conductor + self._queue: asyncio.Queue[SessionUpdate] | None = None + self._prompt_task: asyncio.Task[Any] | None = None + self._prompt_response: PromptResponse | None = None + self._conductor_response: dict[str, Any] | None = None + self._prompt_error: Exception | None = None + self._collected_updates: list[SessionUpdate] = [] + + async def prompt(self, session_id: str, content: list[ContentBlock]) -> None: + """Send a prompt non-blocking — launches background task. + + When conductor is present and intercepts ``session/prompt``, routes + through the proxy chain via ``conductor._route_to_terminal()``. + Otherwise, falls back to ``api.prompt()`` directly. + + Args: + session_id: The ACP session ID to prompt. + content: List of ACP content blocks to send. + + Raises: + RuntimeError: If a prompt is already in progress. + """ + if self._prompt_task is not None and not self._prompt_task.done(): + raise RuntimeError("Prompt already in progress") + + # Initialize queue if not already created + if self._queue is None: + from agentpool.agents.acp_agent.client_handler import ACPClientHandler + + if isinstance(self._notification_source, ACPClientHandler): + self._queue = asyncio.Queue(maxsize=1000) + self._notification_source._stream_queue = self._queue + else: + self._queue = self._notification_source + + self._prompt_response = None + self._conductor_response = None + self._prompt_error = None + self._collected_updates = [] + + # Check if conductor should handle routing + use_conductor = self._conductor is not None and self._conductor._should_intercept( + "session/prompt" + ) + + if use_conductor: + self._prompt_task = asyncio.create_task(self._run_conductor_prompt(session_id, content)) + else: + self._prompt_task = asyncio.create_task(self._run_api_prompt(session_id, content)) + + async def _run_conductor_prompt( + self, + session_id: str, + content: list[ContentBlock], + ) -> dict[str, Any]: + """Route prompt through conductor's proxy chain. + + The conductor routes the request through all intercepting proxies + (forward), sends to the terminal agent, then routes the response + back through proxies in reverse order. + + Notifications arrive via the handler's stream queue during the + blocking send_request call inside the conductor. + """ + try: + params: dict[str, Any] = { + "sessionId": session_id, + "prompt": content, + } + assert self._conductor is not None + response: dict[str, Any] = await self._conductor._route_to_terminal( + "session/prompt", params + ) + except Exception as exc: + self._prompt_error = exc + raise + self._conductor_response = response + return response + + async def _run_api_prompt( + self, + session_id: str, + content: list[ContentBlock], + ) -> PromptResponse: + """Call api.prompt() directly (no proxy chain).""" + try: + response = await self._api.prompt(session_id, content) + except Exception as exc: + self._prompt_error = exc + raise + self._prompt_response = response + return response + + async def stream_events(self) -> AsyncIterator[SessionUpdate]: + """Return an async iterator of session-update notifications. + + Yields :class:`SessionUpdate` items in order as they arrive from the + ACP agent. The iterator signals completion when the background prompt + task finishes. If the task raised an exception, it is propagated after + draining remaining items. + + Yields: + Session update notifications in order. + + Raises: + RuntimeError: If :meth:`prompt` was not called first. + Exception: If the background prompt task raised an exception. + """ + if self._prompt_task is None: + raise RuntimeError("No prompt in progress — call prompt() first") + if self._queue is None: + raise RuntimeError("Queue not initialized") + + prompt_task = self._prompt_task + queue = self._queue + + while True: + get_task = asyncio.create_task(queue.get()) + done, _pending = await asyncio.wait( + [get_task, prompt_task], + return_when=asyncio.FIRST_COMPLETED, + ) + + if get_task in done: + item = get_task.result() + self._collected_updates.append(item) + yield item + + if prompt_task.done(): + if get_task not in done: + get_task.cancel() + break + + # Drain remaining items after task completion + while not queue.empty(): + item = queue.get_nowait() + self._collected_updates.append(item) + yield item + + # Propagate error if the background task failed + if self._prompt_error is not None: + raise self._prompt_error + + @property + def stop_reason(self) -> str | None: + """Return the stop reason after streaming completes. + + Extracts stop_reason from either: + - The conductor response dict (when proxy chain was used) + - The PromptResponse object (when api.prompt was used directly) + + Returns: + The stop reason string, or ``None`` if the response has no stop reason. + + Raises: + RuntimeError: If accessed before streaming completes. + """ + if self._prompt_task is None or not self._prompt_task.done(): + raise RuntimeError("stop_reason not available until streaming completes") + if self._prompt_error is not None: + raise self._prompt_error + # Conductor response path + if self._conductor_response is not None: + result = self._conductor_response.get("result", {}) + if isinstance(result, dict): + return result.get("stopReason") or result.get("stop_reason") + return None + # API response path + if self._prompt_response is not None: + return self._prompt_response.stop_reason + raise RuntimeError("Prompt completed without response or error") + + async def get_messages(self, session_id: str) -> list[SessionUpdate]: + """Retrieve the full message history for a session. + + Returns the list of session updates collected during + :meth:`stream_events`. Should only be called after the prompt + completes. + + Args: + session_id: The ACP session ID. + + Returns: + A list of session updates representing the message history. + """ + return list(self._collected_updates) diff --git a/src/agentpool/agents/acp_agent/client_handler.py b/src/agentpool/agents/acp_agent/client_handler.py index 6a1141560..d26a6b7e7 100644 --- a/src/agentpool/agents/acp_agent/client_handler.py +++ b/src/agentpool/agents/acp_agent/client_handler.py @@ -42,12 +42,13 @@ ReleaseTerminalRequest, RequestPermissionRequest, SessionNotification, + SessionUpdate, TerminalOutputRequest, WaitForTerminalExitRequest, WriteTextFileRequest, ) from agentpool.agents.acp_agent import ACPAgent - from agentpool.agents.acp_agent.session_state import ACPSessionState + from agentpool.agents.acp_agent.session_state import ACPState from agentpool.ui.base import InputProvider logger = get_logger(__name__) @@ -71,8 +72,8 @@ class ACPClientHandler(Client): - Terminal operations (create, output, kill, release) via ProcessManager - Permission request handling via InputProvider - The handler accumulates session updates in an ACPSessionState instance, - allowing the ACPAgent to build the final response from streamed chunks. + The handler tracks session state in an ACPState instance. + Stream data is pushed directly to an async queue (not accumulated in state). Uses ExecutionEnvironment for all file and process operations, enabling swappable backends (local, Docker, E2B, SSH, etc.). @@ -84,7 +85,7 @@ class ACPClientHandler(Client): def __init__( self, agent: ACPAgent[Any], - state: ACPSessionState, + state: ACPState, input_provider: InputProvider | None = None, ) -> None: self._agent = agent @@ -93,6 +94,11 @@ def __init__( self._update_event = TimeoutableEvent() # Map ACP terminal IDs to process manager IDs (for local execution only) self._terminal_to_process: dict[str, str] = {} + # Async queue for stream-data updates (set by ACPClientAdapter). + # When None, stream data falls back to _load_updates capture during session load. + self._stream_queue: asyncio.Queue[SessionUpdate] | None = None + # When False, request_permission skips hook firing (HookProxy handles hooks). + self._hooks_enabled: bool = True # Copy auto_approve from agent (can be updated via set_auto_approve) @property @@ -115,6 +121,27 @@ def allow_terminal(self) -> bool: caps = self._agent._init_request.client_capabilities return bool(caps and caps.terminal) + def set_stream_queue(self, queue: asyncio.Queue[SessionUpdate]) -> None: + """Set the async queue for streaming session updates. + + When set, stream-data updates (text chunks, tool calls, thoughts) + are pushed to this queue instead of being collected in state. + State updates (mode, model, config, commands) are always processed + in-place regardless of the queue. + """ + self._stream_queue = queue + + def set_hooks_enabled(self, enabled: bool) -> None: + """Enable or disable hook firing in request_permission. + + When False, request_permission skips hook firing entirely. + Used by Conductor when HookProxy is active to prevent double-firing. + + Args: + enabled: True to enable hooks, False to disable. + """ + self._hooks_enabled = enabled + async def session_update(self, params: SessionNotification[Any]) -> None: """Handle session update notifications from the agent. @@ -190,7 +217,8 @@ async def session_update(self, params: SessionNotification[Any]) -> None: await self._agent.state_updated.emit(update) logger.debug("Available commands updated", count=len(update.available_commands)) # Also capture during load so replay/restoration works correctly - self.state.add_update(params.update) + if self.state.is_loading: + self.state._load_updates.append(params.update) self._update_event.set() return @@ -201,14 +229,28 @@ async def session_update(self, params: SessionNotification[Any]) -> None: # 3. Switch to agent-owned todos instead of pool-owned # For now, AgentPlanUpdate falls through to stream data. - # Store raw update - conversion happens lazily during consumption - self.state.add_update(params.update) + # Capture during load for replay/restoration. + if self.state.is_loading: + self.state._load_updates.append(params.update) + + # Push stream-data updates to the async queue if set. + # When no queue is set (e.g., during session load without adapter), + # stream data is still available via _load_updates replay. + if self._stream_queue is not None: + await self._stream_queue.put(params.update) + self._update_event.set() async def request_permission( # noqa: PLR0911 self, params: RequestPermissionRequest ) -> RequestPermissionResponse: """Handle permission requests via InputProvider.""" + # When hooks are disabled (HookProxy active), skip hook firing + # to prevent double-firing. HookProxy handles hooks via proxy_successor. + if not self._hooks_enabled: + if params.options: + return RequestPermissionResponse.allowed(params.options[0].option_id) + return RequestPermissionResponse.allowed("") tc = params.tool_call name = tc.title or "operation" logger.info("Permission requested", tool_name=name) diff --git a/src/agentpool/agents/acp_agent/session_state.py b/src/agentpool/agents/acp_agent/session_state.py index 18762c6f5..9707ef70b 100644 --- a/src/agentpool/agents/acp_agent/session_state.py +++ b/src/agentpool/agents/acp_agent/session_state.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections import deque from dataclasses import dataclass, field as dataclass_field from typing import TYPE_CHECKING @@ -24,30 +23,27 @@ @dataclass -class ACPSessionState: +class ACPState: """Tracks state of an ACP session. - Raw ACP SessionUpdate objects are stored as the single source of truth. - Conversion to native events happens lazily during streaming consumption. + Preserves model/mode/config/commands state for the UI layer. + Stream data is pushed directly to an async queue (not stored here). """ session_id: str """The session ID from the ACP server.""" - updates: deque[SessionUpdate] = dataclass_field(default_factory=deque) - """Raw ACP session updates - single source of truth for stream data.""" - current_model_id: str | None = None - """Current model ID from session state (legacy).""" + """Current model ID from session state.""" models: SessionModelState | None = None - """Full model state including available models (legacy).""" + """Full model state including available models.""" modes: SessionModeState | None = None - """Full mode state including available modes (legacy).""" + """Full mode state including available modes.""" current_mode_id: str | None = None - """Current mode ID (legacy).""" + """Current mode ID.""" config_options: list[SessionConfigOption] = dataclass_field(default_factory=list) """Unified session config options (replaces modes/models in newer ACP versions).""" @@ -62,26 +58,9 @@ class ACPSessionState: """Separate list for collecting updates during load (not consumed by streaming).""" def clear(self) -> None: - """Clear stream-related state for a new prompt turn.""" - self.updates.clear() - # Note: Don't clear current_model_id, models, config_options - those persist - - def add_update(self, update: SessionUpdate) -> None: - """Add a raw ACP update to the queue.""" - self.updates.append(update) - # Also collect for load if we're loading - if self.is_loading: - self._load_updates.append(update) - - def pop_update(self) -> SessionUpdate | None: - """Pop and return the next update, or None if empty.""" - if self.updates: - return self.updates.popleft() - return None - - def has_pending_updates(self) -> bool: - """Check if there are unconsumed updates.""" - return len(self.updates) > 0 + """Clear state for a new prompt turn.""" + # Note: Don't clear session_id, current_model_id, models, config_options - + # those persist across turns def start_load(self) -> None: """Start collecting updates for session load.""" diff --git a/src/agentpool/agents/acp_agent/turn.py b/src/agentpool/agents/acp_agent/turn.py index 2a5de9288..677ba16bc 100644 --- a/src/agentpool/agents/acp_agent/turn.py +++ b/src/agentpool/agents/acp_agent/turn.py @@ -26,7 +26,7 @@ from pydantic_ai import ModelMessage - from acp.schema import ContentBlock, PromptResponse, SessionUpdate + from acp.schema import ContentBlock, SessionUpdate from agentpool.agents.context import AgentRunContext from agentpool.agents.events import RichAgentStreamEvent from agentpool.hooks import AgentHooks @@ -36,16 +36,20 @@ class ACPClientProtocol(Protocol): """Protocol defining the ACP client interface expected by ACPTurn. - The ACP client must provide three methods: + The ACP client must provide four capabilities: - - :meth:`prompt` — send a prompt to the remote agent, return a response handle - - :meth:`stream_events` — return an async iterator of session updates + - :meth:`prompt` — send a prompt to the remote agent (non-blocking, returns None) + - :meth:`stream_events` — return an async iterator of session updates (no args) + - :attr:`stop_reason` — return the stop reason after streaming completes - :meth:`get_messages` — return the full list of session updates for history """ - async def prompt(self, session_id: str, content: list[ContentBlock]) -> PromptResponse: ... + async def prompt(self, session_id: str, content: list[ContentBlock]) -> None: ... - def stream_events(self, response: PromptResponse) -> AsyncIterator[SessionUpdate]: ... + def stream_events(self) -> AsyncIterator[SessionUpdate]: ... + + @property + def stop_reason(self) -> str | None: ... async def get_messages(self, session_id: str) -> list[SessionUpdate]: ... @@ -159,7 +163,7 @@ async def execute(self) -> AsyncGenerator[RichAgentStreamEvent[Any]]: # noqa: P self._run_ctx.cancelled = True from agentpool.messaging import ChatMessage - self._final_message = ChatMessage[str]( + self._final_message = ChatMessage( content="", role="assistant", message_id=str(uuid4()), @@ -172,9 +176,9 @@ async def execute(self) -> AsyncGenerator[RichAgentStreamEvent[Any]]: # noqa: P full_prompt = "\n\n".join(self._prompts) if self._prompts else "" content = convert_to_acp_content([full_prompt]) - # --- Phase 1: Send prompt --- + # --- Phase 1: Send prompt (non-blocking) --- try: - response = await self._acp_client.prompt(self._session_id, content) + await self._acp_client.prompt(self._session_id, content) except asyncio.CancelledError: raise except Exception as exc: # noqa: BLE001 @@ -187,7 +191,8 @@ async def execute(self) -> AsyncGenerator[RichAgentStreamEvent[Any]]: # noqa: P # --- Phase 2: Stream events --- try: - async for update in self._acp_client.stream_events(response): + tool_start_times: dict[str, float] = {} + async for update in self._acp_client.stream_events(): native_event = acp_to_native_event(update) if native_event is not None: # Fire advisory tool hooks for tool-related events. @@ -199,6 +204,7 @@ async def execute(self) -> AsyncGenerator[RichAgentStreamEvent[Any]]: # noqa: P raw_input=ti, tool_call_id=tcid, ): + tool_start_times[tcid] = time.perf_counter() await self._fire_pre_tool_hooks(tn, ti, tcid) case ToolCallCompleteEvent( tool_name=tn, @@ -206,11 +212,13 @@ async def execute(self) -> AsyncGenerator[RichAgentStreamEvent[Any]]: # noqa: P tool_result=tr, tool_call_id=tcid, ): + start = tool_start_times.pop(tcid, time.perf_counter()) + tool_duration = (time.perf_counter() - start) * 1000 await self._fire_post_tool_hooks( tn, ti, tr, - 0.0, + tool_duration, tcid, ) case _: @@ -245,16 +253,25 @@ async def execute(self) -> AsyncGenerator[RichAgentStreamEvent[Any]]: # noqa: P ) self._message_history = model_messages + # Get stop_reason from adapter and compute finish_reason + stop_reason = self._acp_client.stop_reason + from agentpool.agents.acp_agent.acp_converters import to_finish_reason + + finish_reason = to_finish_reason(stop_reason) + if final_msg is not None: - self._final_message = final_msg + from dataclasses import replace as dc_replace + + self._final_message = dc_replace(final_msg, finish_reason=finish_reason) else: from agentpool.messaging import ChatMessage - self._final_message = ChatMessage[str]( + self._final_message = ChatMessage( content="", role="assistant", message_id=str(uuid4()), session_id=self._session_id, + finish_reason="stop", ) yield StreamCompleteEvent(message=self._final_message) diff --git a/src/agentpool/mcp_server/__init__.py b/src/agentpool/mcp_server/__init__.py index 3f6b140ef..3cc0f8164 100644 --- a/src/agentpool/mcp_server/__init__.py +++ b/src/agentpool/mcp_server/__init__.py @@ -1,6 +1,54 @@ """MCP server integration for AgentPool.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol + from agentpool.mcp_server.client import MCPClient from agentpool.mcp_server.tool_bridge import ToolManagerBridge -__all__ = ["MCPClient", "ToolManagerBridge"] + +if TYPE_CHECKING: + from agentpool.agents.base_agent import BaseAgent + + +class ToolBridge(Protocol): + """Abstract interface for an MCP tool bridge. + + Hides the concrete ToolManagerBridge implementation behind a protocol, + allowing callers to avoid importing the concrete class directly. + """ + + @property + def url(self) -> str: + """Get the server URL.""" + ... + + @property + def resolved_server_name(self) -> str: + """Get the resolved server name.""" + ... + + async def start(self) -> None: + """Start the bridge.""" + ... + + async def stop(self) -> None: + """Stop the bridge.""" + ... + + +def create_tool_bridge(node: BaseAgent[Any, Any], *, server_name: str | None = None) -> ToolBridge: + """Create a ToolBridge backed by ToolManagerBridge. + + Args: + node: The agent node whose tools to expose. + server_name: Optional name for the MCP server. + + Returns: + A ToolBridge protocol instance (backed by ToolManagerBridge). + """ + return ToolManagerBridge(node=node, server_name=server_name) + + +__all__ = ["MCPClient", "ToolBridge", "ToolManagerBridge", "create_tool_bridge"] diff --git a/src/agentpool/models/acp_agents/base.py b/src/agentpool/models/acp_agents/base.py index fa0b99686..f9ddc308d 100644 --- a/src/agentpool/models/acp_agents/base.py +++ b/src/agentpool/models/acp_agents/base.py @@ -12,6 +12,7 @@ ) from pydantic import ConfigDict, Field +from agentpool.models.acp_agents.proxy_chain import ProxyChainConfig # noqa: TC001 from agentpool.models.fields import EnvVarsField # noqa: TC001 from agentpool_config import AnyToolConfig, BaseToolConfig from agentpool_config.nodes import BaseAgentConfig @@ -24,7 +25,7 @@ from agentpool.agents.acp_agent import ACPAgent from agentpool.common_types import AnyEventHandlerType from agentpool.delegation import AgentPool - from agentpool.resource_providers import ResourceProvider + from agentpool.tools.factory import ToolsetFactory from agentpool.ui.base import InputProvider @@ -44,6 +45,17 @@ class BaseACPAgentConfig(BaseAgentConfig): type: Literal["acp"] = Field("acp", init=False) """Top-level discriminator for agent type.""" + proxy_chain: list[ProxyChainConfig] | None = Field( + default=None, + title="Proxy Chain", + description=( + "Ordered list of proxy configurations that intercept and process " + "messages before reaching the terminal ACP agent. Each entry defines " + "a middleware-style transformation step in the message pipeline." + ), + ) + """Proxy chain configuration for ACP agents.""" + cwd: str | None = Field( default=None, title="Working Directory", @@ -141,19 +153,23 @@ def get_registry_id(self) -> str | None: """Get the ACP registry agent ID, if this is a registry-based agent.""" return None - def get_tool_providers(self) -> list[ResourceProvider]: - """Get all resource providers for this agent's tools.""" - from agentpool.resource_providers import StaticResourceProvider + def get_tool_factories(self) -> list[ToolsetFactory]: + """Get all toolset factories for this agent's tools.""" from agentpool.tools.base import Tool + from agentpool.tools.factory import ( + AdapterToolsetFactory, + StaticToolsetFactory, + ) - providers: list[ResourceProvider] = [] + factories: list[ToolsetFactory] = [] static_tools: list[Tool] = [] for tool_config in self.tools: try: match tool_config: case BaseToolsetConfig(): - providers.append(tool_config.get_provider()) + provider = tool_config.get_provider() + factories.append(AdapterToolsetFactory(provider)) case str(): static_tools.append(Tool.from_callable(tool_config)) case BaseToolConfig(): @@ -164,9 +180,9 @@ def get_tool_providers(self) -> list[ResourceProvider]: continue if static_tools: - providers.append(StaticResourceProvider(name="tools", tools=static_tools)) + factories.append(StaticToolsetFactory(name="tools", tools=static_tools)) - return providers + return factories def get_protocol_version(self) -> int: """Get the ACP protocol version for this agent. diff --git a/src/agentpool/models/acp_agents/proxy_chain.py b/src/agentpool/models/acp_agents/proxy_chain.py new file mode 100644 index 000000000..b4369398c --- /dev/null +++ b/src/agentpool/models/acp_agents/proxy_chain.py @@ -0,0 +1,28 @@ +"""Proxy chain configuration models.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field, model_validator + + +class BaseProxyConfig(BaseModel): + """Base configuration for proxy chain entries.""" + + type: str = Field(..., description="Proxy type discriminator") + + @model_validator(mode="after") + def validate_proxy_type(self) -> BaseProxyConfig: + """Validate that the proxy type is a known type. + + Currently rejects all types since no concrete proxy types exist yet. + T17 will convert this to a proper discriminated union. + """ + known_types: frozenset[str] = frozenset({"hook", "context_injection", "tool_provider"}) + if self.type not in known_types: + msg = f"Unknown proxy type: {self.type}" + raise ValueError(msg) + return self + + +# T17 will convert this to Annotated[Union[...], Field(discriminator="type")] +ProxyChainConfig = BaseProxyConfig diff --git a/src/agentpool_server/acp_server/event_converter.py b/src/agentpool_server/acp_server/event_converter.py index 848bf79e3..1b30c96fa 100644 --- a/src/agentpool_server/acp_server/event_converter.py +++ b/src/agentpool_server/acp_server/event_converter.py @@ -12,7 +12,7 @@ from dataclasses import dataclass, field import json -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, Protocol, runtime_checkable import uuid from pydantic import BaseModel @@ -42,6 +42,7 @@ AgentThoughtChunk, ContentToolCallContent, Cost, + PlanEntry as ACPPlanEntry, ToolCallLocation, ToolCallProgress, ToolCallStart, @@ -78,7 +79,7 @@ if TYPE_CHECKING: - from collections.abc import AsyncIterator + from collections.abc import AsyncIterator, Sequence from acp.schema.tool_call import ToolCallContent, ToolCallKind from agentpool.agents.events import RichAgentStreamEvent @@ -98,6 +99,237 @@ ) +# ============================================================================ +# Stateless Conversion Functions +# +# These functions perform pure data transformation with no internal state. +# They can be called directly during passthrough or composed by a stateful +# converter (ACPEventConverter). Extracting them enables: +# - Zero-conversion passthrough: skip these functions entirely +# - Selective conversion: only convert specific event types +# - Testability: each function is independently testable +# ============================================================================ + + +def build_text_chunk(delta: str, message_id: str) -> AgentMessageChunk: + """Convert a text delta to an AgentMessageChunk. + + Args: + delta: The text content delta. + message_id: The current message ID for correlation. + + Returns: + An AgentMessageChunk containing the text delta. + """ + return AgentMessageChunk.text(delta, message_id=message_id) + + +def build_thought_chunk(delta: str, message_id: str) -> AgentThoughtChunk: + """Convert a thinking/reasoning delta to an AgentThoughtChunk. + + Args: + delta: The thinking content delta. + message_id: The current message ID for correlation. + + Returns: + An AgentThoughtChunk containing the thinking delta. + """ + return AgentThoughtChunk.text(delta, message_id=message_id) + + +def build_usage_update(message: Any) -> UsageUpdate: + """Extract usage information from a completed stream message. + + Builds a UsageUpdate from the message's usage and cost info. + This is a stateless extraction — the caller is responsible for + tracking last_usage if needed. + + Args: + message: The ChatMessage from a StreamCompleteEvent. + + Returns: + A UsageUpdate with token counts and optional cost. + """ + request_usage = message.usage + cost_obj: Cost | None = None + if message.cost_info and message.cost_info.total_cost: + cost_obj = Cost( + amount=float(message.cost_info.total_cost), + currency="USD", + ) + return UsageUpdate( + used=request_usage.total_tokens, + size=request_usage.total_tokens, + cost=cost_obj, + ) + + +def build_usage_from_message(message: Any) -> Usage | None: + """Extract a Usage object from a completed stream message. + + Args: + message: The ChatMessage from a StreamCompleteEvent. + + Returns: + A Usage object with token breakdown, or None if extraction fails. + """ + request_usage = message.usage + thought = request_usage.details.get("reasoning_tokens") or None + return Usage( + total_tokens=request_usage.total_tokens, + input_tokens=request_usage.input_tokens, + output_tokens=request_usage.output_tokens, + thought_tokens=thought, + cached_read_tokens=request_usage.cache_read_tokens or None, + cached_write_tokens=request_usage.cache_write_tokens or None, + ) + + +def convert_plan_entries(entries: Sequence[Any]) -> AgentPlanUpdate: + """Convert plan entries to ACP format. + + Args: + entries: A sequence of plan entry objects with content, priority, status. + + Returns: + An AgentPlanUpdate with converted entries. + """ + acp_entries = [ + ACPPlanEntry(content=e.content, priority=e.priority, status=e.status) for e in entries + ] + return AgentPlanUpdate(entries=acp_entries) + + +def build_error_text(message: str, agent_name: str | None) -> str: + """Format an error message for display as agent text. + + Args: + message: The error message. + agent_name: Optional agent name for prefix. + + Returns: + Formatted error text string. + """ + agent_prefix = f"[{agent_name}] " if agent_name else "" + return f"\n\n❌ **Error**: {agent_prefix}{message}\n\n" + + +def build_run_failed_text(run_id: str, exc: BaseException) -> str: + """Format a run failure message for display as agent text. + + Args: + run_id: The failed run's identifier. + exc: The exception that caused the failure. + + Returns: + Formatted failure text string. + """ + return f"\n\n❌ **Run Failed** [{run_id}]: {exc}\n\n" + + +def is_cancellation_exception(exc: BaseException) -> bool: + """Check if an exception represents a cancellation. + + Args: + exc: The exception to check. + + Returns: + True if the exception is an asyncio.CancelledError or a RuntimeError + containing "cancelled" in its message. + """ + import asyncio + + return isinstance(exc, asyncio.CancelledError) or ( + isinstance(exc, RuntimeError) and "cancelled" in str(exc).lower() + ) + + +# ============================================================================ +# Event Converter Component Protocol +# +# Defines the interface for event conversion components. Implementations may: +# - Perform full conversion (ACPEventConverter — stateful, tracks tools) +# - Skip conversion entirely during passthrough (zero-conversion) +# +# During proxy chain passthrough, a PassthroughEventConverter can be +# substituted to skip event-to-ACP conversion, forwarding raw events +# to the next proxy or terminal agent. +# ============================================================================ + + +@runtime_checkable +class EventConverterComponent(Protocol): + """Interface for event conversion components. + + This protocol defines the contract for converting agent stream events + to ACP session updates. The ACPEventConverter is the primary implementation; + a future PassthroughEventConverter can implement this to skip conversion + during proxy chain passthrough (zero-conversion mode). + + Attributes: + subagent_display_mode: How to display subagent output. + raw_input_mode: How to emit tool call raw_input. + subagent_meta: _meta dict for subagent notifications, None for root. + last_usage: Usage from the last completed stream, if available. + """ + + @property + def subagent_display_mode(self) -> Literal["legacy", "zed", "qwen"]: + """How to display subagent output.""" + ... + + @property + def raw_input_mode(self) -> Literal["dict", "skip", "json_str"]: + """How to emit tool call raw_input.""" + ... + + @property + def subagent_meta(self) -> dict[str, Any] | None: + """Build _meta dict for subagent notifications. None for root sessions.""" + ... + + @property + def last_usage(self) -> Usage | None: + """Usage from the last completed stream, if available.""" + ... + + def reset(self) -> None: + """Reset converter state for a new run.""" + ... + + async def convert(self, event: RichAgentStreamEvent[Any]) -> AsyncIterator[ACPSessionUpdate]: + """Convert an agent event to zero or more ACP session updates. + + Args: + event: The agent stream event to convert. + + Yields: + ACP session update objects. + """ + ... + + async def cancel_pending_tools(self) -> AsyncIterator[ToolCallProgress]: + """Cancel all pending tool calls. + + Yields ToolCallProgress notifications with status="completed" for all + tool calls that were started but not completed. + + Yields: + ToolCallProgress notifications for each pending tool call. + """ + ... + + async def build_subagent_completed( + self, child_session_id: str + ) -> AsyncIterator[ToolCallProgress]: + """Emit a completion notification for a subagent session. + + Args: + child_session_id: The child session ID that has completed. + """ + ... + + def get_compaction_text(trigger: str) -> str: if trigger == "auto": return "\n\n---\n\n📦 **Context compaction** triggered. Summarizing...\n\n---\n\n" @@ -393,7 +625,6 @@ async def convert( # noqa: PLR0915 """Convert an agent event to zero or more ACP session updates.""" from acp.schema import ( FileEditToolCallContent, - PlanEntry as ACPPlanEntry, TerminalToolCallContent, ) from agentpool_server.acp_server.syntax_detection import format_zed_code_block @@ -404,7 +635,7 @@ async def convert( # noqa: PLR0915 PartStartEvent(part=TextPart(content=delta)) | PartDeltaEvent(delta=TextPartDelta(content_delta=delta)) ): - yield AgentMessageChunk.text(delta, message_id=self._current_message_id) + yield build_text_chunk(delta, self._current_message_id) # Thinking/reasoning case ( @@ -412,7 +643,7 @@ async def convert( # noqa: PLR0915 | PartDeltaEvent(delta=ThinkingPartDelta(content_delta=delta)) ): if delta is not None: - yield AgentThoughtChunk.text(delta, message_id=self._current_message_id) + yield build_thought_chunk(delta, self._current_message_id) # Builtin tool call started (e.g., WebSearchTool, CodeExecutionTool) case PartStartEvent(part=NativeToolCallPart() as part): @@ -666,30 +897,11 @@ async def convert( # noqa: PLR0915 pass # No notification needed case StreamCompleteEvent(message=message): - request_usage = message.usage - thought = request_usage.details.get("reasoning_tokens") or None - self.last_usage = Usage( - total_tokens=request_usage.total_tokens, - input_tokens=request_usage.input_tokens, - output_tokens=request_usage.output_tokens, - thought_tokens=thought, - cached_read_tokens=request_usage.cache_read_tokens or None, - cached_write_tokens=request_usage.cache_write_tokens or None, - ) - cost_obj: Cost | None = None - if message.cost_info and message.cost_info.total_cost: - cost_obj = Cost( - amount=float(message.cost_info.total_cost), - currency="USD", - ) + self.last_usage = build_usage_from_message(message) # Always yield UsageUpdate on stream completion so clients # know the turn has ended — especially critical for inject- # triggered turns where no PromptResponse(stop_reason) is sent. - yield UsageUpdate( - used=request_usage.total_tokens, - size=request_usage.total_tokens, # best approximation - cost=cost_obj, - ) + yield build_usage_update(message) # Turn-complete signal: explicit end-of-turn barrier for clients. # Based on draft RFD PR #644 (not yet merged into ACP spec). # See: https://github.com/agentclientprotocol/agent-client-protocol/pull/644 @@ -699,11 +911,7 @@ async def convert( # noqa: PLR0915 yield TurnCompleteUpdate(stop_reason="end_turn") case PlanUpdateEvent(entries=entries): - acp_entries = [ - ACPPlanEntry(content=e.content, priority=e.priority, status=e.status) - for e in entries - ] - yield AgentPlanUpdate(entries=acp_entries) + yield convert_plan_entries(entries) case CompactionEvent(trigger=trigger, phase=phase) if phase == "starting": text = get_compaction_text(trigger) @@ -823,8 +1031,7 @@ async def convert( # noqa: PLR0915 case RunErrorEvent(message=message, agent_name=agent_name): # TurnCompleteUpdate is required here — without it, clients # with turn_complete support stay stuck in "running" state. - agent_prefix = f"[{agent_name}] " if agent_name else "" - error_text = f"\n\n❌ **Error**: {agent_prefix}{message}\n\n" + error_text = build_error_text(message, agent_name) yield AgentMessageChunk.text(error_text, message_id=self._current_message_id) async for cancel_update in self.cancel_pending_tools(): yield cancel_update @@ -836,18 +1043,13 @@ async def convert( # noqa: PLR0915 # Unlike RunErrorEvent (agent-level), RunFailedEvent indicates # the run itself crashed — the session cannot continue. - # Check if this is a cancellation (session/cancel notification) - import asyncio - - is_cancellation = isinstance(exc, asyncio.CancelledError) or ( - isinstance(exc, RuntimeError) and "cancelled" in str(exc).lower() - ) + is_cancellation = is_cancellation_exception(exc) stop_reason: Literal["end_turn", "cancelled"] = ( "cancelled" if is_cancellation else "end_turn" ) if not is_cancellation: - error_text = f"\n\n❌ **Run Failed** [{run_id}]: {exc}\n\n" + error_text = build_run_failed_text(run_id, exc) yield AgentMessageChunk.text(error_text, message_id=self._current_message_id) async for cancel_update in self.cancel_pending_tools(): yield cancel_update @@ -910,3 +1112,85 @@ async def convert( # noqa: PLR0915 # Graceful fallback for unknown event types # Handles future events like ToolRequiresAuthEvent without crashing logger.debug("Unhandled event", event_type=type(event).__name__) + + +# ============================================================================ +# Passthrough Event Converter +# +# A zero-conversion implementation of EventConverterComponent. During proxy +# chain passthrough, this converter yields nothing — events are forwarded +# raw to the next proxy or terminal agent without ACP-specific conversion. +# ============================================================================ + + +@dataclass +class PassthroughEventConverter: + """No-op event converter for proxy chain passthrough. + + Implements EventConverterComponent but performs zero conversion. + All events are silently consumed (yields nothing). This enables + proxy chains to skip the convert→ACP→convert round-trip when + the terminal agent handles its own event delivery. + + The converter still tracks usage and provides subagent metadata + so the proxy chain can maintain basic accounting. + """ + + subagent_display_mode: Literal["legacy", "zed", "qwen"] = "legacy" + raw_input_mode: Literal["dict", "skip", "json_str"] = "dict" + client_supports_turn_complete: bool = False + subagent_context: SubagentContext | None = None + last_usage: Usage | None = field(default=None, init=False) + + @property + def subagent_meta(self) -> dict[str, Any] | None: + """Build _meta dict for subagent notifications. None for root sessions.""" + if self.subagent_context is None: + return None + return { + "parentToolCallId": self.subagent_context.parent_tool_call_id, + "subagentType": self.subagent_context.subagent_type, + "provenance": "subagent", + } + + def reset(self) -> None: + """Reset converter state for a new run.""" + self.last_usage = None + + async def convert(self, event: RichAgentStreamEvent[Any]) -> AsyncIterator[ACPSessionUpdate]: + """No-op conversion — yields nothing during passthrough. + + Args: + event: The agent stream event (ignored). + + Yields: + Nothing — this is a zero-conversion passthrough. + """ + # Extract usage from stream completion for accounting + if isinstance(event, StreamCompleteEvent): + self.last_usage = build_usage_from_message(event.message) + return + yield # Make this an async generator + + async def cancel_pending_tools(self) -> AsyncIterator[ToolCallProgress]: + """No-op cancellation — no tools tracked during passthrough. + + Yields: + Nothing — no tool state is tracked. + """ + return + yield # Make this an async generator + + async def build_subagent_completed( + self, child_session_id: str + ) -> AsyncIterator[ToolCallProgress]: + """No-op subagent completion — no subagent tracking during passthrough. + + Args: + child_session_id: The child session ID (ignored). + + Yields: + Nothing — no subagent state is tracked. + """ + return + yield # Make this an async generator diff --git a/src/agentpool_server/acp_server/session.py b/src/agentpool_server/acp_server/session.py index c8451c75e..193f27031 100644 --- a/src/agentpool_server/acp_server/session.py +++ b/src/agentpool_server/acp_server/session.py @@ -16,7 +16,6 @@ import anyio from exxec.acp_provider import ACPExecutionEnvironment import logfire -from pydantic_ai import UsageLimitExceeded from slashed import CommandStore from tokonomics.model_discovery.model_info import ModelInfo @@ -35,9 +34,7 @@ from agentpool.skills.uri_resolver import MAX_PROVIDER_NAME_LENGTH from agentpool_server.acp_server.converters import ( convert_acp_mcp_server_to_config, - from_acp_content, ) -from agentpool_server.acp_server.event_converter import ACPEventConverter from agentpool_server.acp_server.input_provider import ACPInputProvider from agentpool_server.opencode_server.skill_bridge import create_skill_command @@ -50,10 +47,8 @@ from acp import Client, RequestPermissionRequest, RequestPermissionResponse from acp.schema import ( - ContentBlock, Implementation, McpServer, - StopReason, Usage, ) from agentpool.agents.base_agent import BaseAgent, StateUpdate @@ -135,18 +130,6 @@ def split_commands( return commands, non_command_content -def infer_stop_reason(error_msg: str) -> StopReason: - """Infers the reason for stopping the session based on the error message.""" - if "request_limit" in error_msg: - return "max_turn_requests" - if any(limit in error_msg for limit in ["tokens_limit", "token_limit"]): - return "max_tokens" - # Tool call limits don't have a direct ACP stop reason, treat as refusal - if "tool_calls_limit" in error_msg or "tool call" in error_msg: - return "refusal" - return "max_tokens" # Default to max_tokens for other usage limits - - @dataclass class ACPSession: """Individual ACP session state and management. @@ -217,7 +200,6 @@ def __post_init__(self) -> None: self.log = logger.bind(session_id=self.session_id) self._task_lock = asyncio.Lock() self._cancelled = False - self._current_converter: ACPEventConverter | None = None self.last_usage: Usage | None = None self.fs = ACPFileSystem( self.client, @@ -641,10 +623,6 @@ async def cancel(self) -> None: This actively interrupts the running agent by calling its interrupt() method, which handles protocol-specific cancellation (e.g., sending CancelNotification for ACP agents, etc.). - - Note: - Tool call cleanup is handled in process_prompt() to avoid race conditions - with the converter state being modified from multiple async contexts. """ self._cancelled = True self.log.info("Session cancelled, interrupting agent") @@ -657,123 +635,6 @@ def is_cancelled(self) -> bool: """Check if the session is cancelled.""" return self._cancelled - async def process_prompt(self, content_blocks: Sequence[ContentBlock]) -> StopReason: # noqa: PLR0911 - """Process a prompt request and stream responses. - - Args: - content_blocks: List of content blocks from the prompt request - - Returns: - Stop reason - """ - self._cancelled = False - fs = self.agent.env.get_fs() - contents = [from_acp_content(i, fs=fs) for i in content_blocks] - self.log.debug("Converted content", content=contents) - if not contents: - self.log.warning("Empty prompt received") - return "refusal" - commands, non_command_content = split_commands(contents, self.command_store) - async with self._task_lock: - if commands: # Process commands if found - for command in commands: - self.log.info("Processing slash command", command=command) - await self.execute_slash_command(command) - - # If only commands and no staged content, end turn - if not non_command_content and len(self.agent.staged_content) == 0: - return "end_turn" - - self.log.debug("Processing prompt", content_items=len(non_command_content)) - event_count = 0 - # Derive turn-complete support from client capabilities - client_supports_turn_complete = ( - bool(self.client_capabilities.turn_complete) - if self.client_capabilities is not None - else False - ) - # Create a new event converter for this prompt - converter = ACPEventConverter( - subagent_display_mode=self.subagent_display_mode, - raw_input_mode=self.raw_input_mode, - client_supports_turn_complete=client_supports_turn_complete, - ) - self._current_converter = converter # Track for cancellation - - # Route through SessionPool for unified session management. - # MCP tools are handled via McpConfigSnapshot → as_capability() → - # MCPToolset, not through agent.tools.providers. - agent_pool_ref = getattr(self.agent, "agent_pool", None) - session_pool = agent_pool_ref.session_pool if agent_pool_ref is not None else None - try: - if session_pool is not None: - stream = session_pool.run_stream( - self.session_id, - *non_command_content, - input_provider=self.input_provider, - deps=self, - ) - else: - raise RuntimeError( # noqa: TRY301 - f"SessionPool is required for prompt processing " - f"in session {self.session_id}" - ) - - async for event in stream: - if self._cancelled: - self.log.info("Cancelled during event loop, cleaning up tool calls") - # Send cancellation notifications for any pending tool calls - # This happens in the same async context as the converter - async for cancel_update in converter.cancel_pending_tools(): - await self.notifications.send_update(cancel_update) - # CRITICAL: Allow time for client to process tool completion notifications - # before sending PromptResponse. Without this delay, the client may receive - # and process the PromptResponse before the tool notifications, causing UI - # state desync where subsequent prompts appear stuck/unresponsive. - # This is needed because even though send() awaits the write, the client - # may process messages asynchronously or out of order. - await anyio.sleep(0.05) - self._current_converter = None - return "cancelled" - - event_count += 1 - async for update in converter.convert(event): - await self.notifications.send_update(update) - # Yield control to allow notifications to be sent immediately - await anyio.sleep(0.01) - self.log.info("Streaming finished", events_processed=event_count) - except asyncio.CancelledError: - # Task was cancelled (e.g., via interrupt()) - return proper stop reason - # This is critical: CancelledError doesn't inherit from Exception, - # so we must catch it explicitly to send the PromptResponse - self.log.info("Stream cancelled via CancelledError, cleaning up tool calls") - # Send cancellation notifications for any pending tool calls - async for cancel_update in converter.cancel_pending_tools(): - await self.notifications.send_update(cancel_update) - # CRITICAL: Allow time for client to process tool completion notifications - # before sending PromptResponse. See comment in cancellation branch above. - await anyio.sleep(0.05) - self._current_converter = None - return "cancelled" - except UsageLimitExceeded as e: - self.log.info("Usage limit exceeded", error=str(e)) - return infer_stop_reason(str(e)) - except Exception as e: - self._current_converter = None # Clear converter reference - self.log.exception("Error during streaming") - # Send error as toast notification instead of polluting chat history - await self._send_toast( - message=f"Agent error: {e}", - level="error", - ) - await anyio.sleep(0.05) # Allow network buffers to flush - return "end_turn" - else: - # Title generation is now handled automatically by log_session - self.last_usage = converter.last_usage - self._current_converter = None # Clear converter reference - return "end_turn" - async def _send_toast( self, message: str, @@ -961,3 +822,30 @@ async def execute_slash_command(self, command_text: str) -> None: def register_update_callback(self, callback: Callable[[], None]) -> None: """Register callback for command updates.""" self._update_callbacks.append(callback) + + async def process_prompt(self, content_blocks: list[Any]) -> None: + """Compatibility wrapper for prompt execution. + + Runs the agent, converts events to ACP session updates, and sends + them via the client. Used by test harnesses and skill command bridges. + + Args: + content_blocks: List of ACP content blocks to send as prompt. + """ + from agentpool_server.acp_server.event_converter import ACPEventConverter + + # Extract text from content blocks + prompt_text = "" + for block in content_blocks: + if isinstance(block, dict) and "text" in block: + prompt_text += block["text"] + elif hasattr(block, "text"): + prompt_text += block.text + + converter = ACPEventConverter() + async for event in self.agent.run_stream(prompt_text): + async for update in converter.convert(event): + from acp.schema import SessionNotification + + notification = SessionNotification(sessionId=self.session_id, update=update) + await self.client.session_update(notification) diff --git a/tests/acp/test_client_handler_session_update.py b/tests/acp/test_client_handler_session_update.py index 91e982f4b..e805f696d 100644 --- a/tests/acp/test_client_handler_session_update.py +++ b/tests/acp/test_client_handler_session_update.py @@ -27,7 +27,7 @@ ) from agentpool.agents.acp_agent import ACPAgent from agentpool.agents.acp_agent.client_handler import ACPClientHandler -from agentpool.agents.acp_agent.session_state import ACPSessionState +from agentpool.agents.acp_agent.session_state import ACPState def _mock_agent() -> MagicMock: @@ -54,64 +54,23 @@ def mock_agent() -> MagicMock: @pytest.fixture -def session_state() -> ACPSessionState: - """Provide a fresh ACPSessionState.""" - return ACPSessionState(session_id="test-session") +def session_state() -> ACPState: + """Provide a fresh ACPState.""" + return ACPState(session_id="test-session") @pytest.fixture -def handler(mock_agent: MagicMock, session_state: ACPSessionState) -> ACPClientHandler: +def handler(mock_agent: MagicMock, session_state: ACPState) -> ACPClientHandler: """Provide an ACPClientHandler with mocked agent and real state.""" return ACPClientHandler(agent=mock_agent, state=session_state) # type: ignore[reportAbstractUsage] # ============================================================================= -# Stream data updates (should be added to state.updates) +# Stream data updates are pushed directly to async queue (T3). +# Verifying queued delivery belongs in the T3 async-queue tests. # ============================================================================= -@pytest.mark.unit -async def test_user_message_chunk_added_to_updates( - handler: ACPClientHandler, session_state: ACPSessionState -) -> None: - """UserMessageChunk should be added to state.updates.""" - chunk = UserMessageChunk.text("hello") - notification = SessionNotification(session_id="test-session", update=chunk) - - await handler.session_update(notification) - - assert len(session_state.updates) == 1 - assert session_state.updates[0] == chunk - - -@pytest.mark.unit -async def test_agent_message_chunk_added_to_updates( - handler: ACPClientHandler, session_state: ACPSessionState -) -> None: - """AgentMessageChunk should be added to state.updates.""" - chunk = AgentMessageChunk.text("response") - notification = SessionNotification(session_id="test-session", update=chunk) - - await handler.session_update(notification) - - assert len(session_state.updates) == 1 - assert session_state.updates[0] == chunk - - -@pytest.mark.unit -async def test_tool_call_start_added_to_updates( - handler: ACPClientHandler, session_state: ACPSessionState -) -> None: - """ToolCallStart should be added to state.updates.""" - tool_call = ToolCallStart(tool_call_id="tc-1", title="Reading file") - notification = SessionNotification(session_id="test-session", update=tool_call) - - await handler.session_update(notification) - - assert len(session_state.updates) == 1 - assert session_state.updates[0] == tool_call - - # ============================================================================= # AvailableCommandsUpdate handling # ============================================================================= @@ -119,7 +78,7 @@ async def test_tool_call_start_added_to_updates( @pytest.mark.unit async def test_available_commands_stored_in_state( - handler: ACPClientHandler, session_state: ACPSessionState + handler: ACPClientHandler, session_state: ACPState ) -> None: """AvailableCommandsUpdate should be stored in state.available_commands.""" cmd = AvailableCommand.create(name="test-cmd", description="A test command") @@ -134,28 +93,27 @@ async def test_available_commands_stored_in_state( @pytest.mark.unit -async def test_available_commands_added_to_updates( - handler: ACPClientHandler, session_state: ACPSessionState +async def test_available_commands_triggers_update_event( + handler: ACPClientHandler, session_state: ACPState ) -> None: - """AvailableCommandsUpdate should now be added to state.updates (bug fixed).""" + """AvailableCommandsUpdate should fire the update event (used for wakeup signalling).""" cmd = AvailableCommand.create(name="test-cmd", description="A test command") update = AvailableCommandsUpdate(available_commands=[cmd]) notification = SessionNotification(session_id="test-session", update=update) await handler.session_update(notification) - assert len(session_state.updates) == 1 - assert session_state.updates[0] == update + assert handler._update_event.is_set() @pytest.mark.unit async def test_available_commands_captured_in_load_updates( - handler: ACPClientHandler, session_state: ACPSessionState + handler: ACPClientHandler, session_state: ACPState ) -> None: - """AvailableCommandsUpdate should be captured during load_session (bug fixed). + """AvailableCommandsUpdate should be captured in _load_updates during load session. Previously, session_update() returned early for AvailableCommandsUpdate, - so it never called state.add_update(), causing _load_updates to miss it. + so it never reached the load-capture logic, causing _load_updates to miss it. """ session_state.start_load() cmd = AvailableCommand.create(name="test-cmd", description="A test command") @@ -171,7 +129,7 @@ async def test_available_commands_captured_in_load_updates( @pytest.mark.unit async def test_stream_updates_captured_in_load_updates( - handler: ACPClientHandler, session_state: ACPSessionState + handler: ACPClientHandler, session_state: ACPState ) -> None: """When is_loading=True, stream updates should be captured in _load_updates.""" session_state.start_load() @@ -187,7 +145,7 @@ async def test_stream_updates_captured_in_load_updates( @pytest.mark.unit async def test_all_updates_captured_when_is_loading( - handler: ACPClientHandler, session_state: ACPSessionState + handler: ACPClientHandler, session_state: ACPState ) -> None: """When is_loading=True, all stream updates should be in _load_updates.""" session_state.start_load() @@ -213,7 +171,7 @@ async def test_all_updates_captured_when_is_loading( @pytest.mark.unit async def test_current_mode_update_sets_state( - handler: ACPClientHandler, session_state: ACPSessionState + handler: ACPClientHandler, session_state: ACPState ) -> None: """CurrentModeUpdate should set state.current_mode_id and modes.current_mode_id.""" session_state.modes = SessionModeState( @@ -227,7 +185,6 @@ async def test_current_mode_update_sets_state( assert session_state.current_mode_id == "code" assert session_state.modes.current_mode_id == "code" - assert len(session_state.updates) == 0 # State updates don't go to updates queue @pytest.mark.unit @@ -237,7 +194,7 @@ async def test_current_mode_update_emits_signal( """CurrentModeUpdate should emit state_updated signal with ModeInfo.""" from agentpool.agents.modes import ModeInfo - session_state = ACPSessionState(session_id="test-session") + session_state = ACPState(session_id="test-session") session_state.modes = SessionModeState( available_modes=[SessionMode(id="chat", name="Chat", description="Chat mode")], current_mode_id="chat", @@ -256,7 +213,7 @@ async def test_current_mode_update_emits_signal( @pytest.mark.unit async def test_current_model_update_sets_state( - handler: ACPClientHandler, session_state: ACPSessionState + handler: ACPClientHandler, session_state: ACPState ) -> None: """CurrentModelUpdate should set state.current_model_id and models.current_model_id.""" from acp.schema import ModelInfo as ACPModelInfo @@ -272,7 +229,6 @@ async def test_current_model_update_sets_state( assert session_state.current_model_id == "gpt-3" assert session_state.models.current_model_id == "gpt-3" - assert len(session_state.updates) == 0 @pytest.mark.unit @@ -282,7 +238,7 @@ async def test_current_model_update_emits_signal( """CurrentModelUpdate should emit state_updated signal with ModelInfo.""" from tokonomics.model_discovery.model_info import ModelInfo - session_state = ACPSessionState(session_id="test-session") + session_state = ACPState(session_id="test-session") from acp.schema import ModelInfo as ACPModelInfo session_state.models = SessionModelState( @@ -309,7 +265,7 @@ async def test_current_model_update_emits_signal( @pytest.mark.unit async def test_config_option_update_sets_state( - handler: ACPClientHandler, session_state: ACPSessionState + handler: ACPClientHandler, session_state: ACPState ) -> None: """ConfigOptionUpdate should update the matching config option's current_value.""" session_state.config_options = [ @@ -328,7 +284,6 @@ async def test_config_option_update_sets_state( await handler.session_update(notification) assert session_state.config_options[0].current_value == "light" - assert len(session_state.updates) == 0 @pytest.mark.unit @@ -336,7 +291,7 @@ async def test_config_option_update_calls_agent_update_state( handler: ACPClientHandler, mock_agent: MagicMock ) -> None: """ConfigOptionUpdate should call agent.update_state().""" - session_state = ACPSessionState(session_id="test-session") + session_state = ACPState(session_id="test-session") session_state.config_options = [ SessionConfigOption( id="theme", @@ -366,7 +321,7 @@ async def test_available_commands_captured_in_load_updates_full( mock_agent: MagicMock, ) -> None: """Verify AvailableCommandsUpdate is properly captured during load after fix.""" - session_state = ACPSessionState(session_id="test-session") + session_state = ACPState(session_id="test-session") session_state.start_load() handler = ACPClientHandler(agent=mock_agent, state=session_state) diff --git a/tests/acp/test_conductor.py b/tests/acp/test_conductor.py new file mode 100644 index 000000000..5f77cab96 --- /dev/null +++ b/tests/acp/test_conductor.py @@ -0,0 +1,794 @@ +"""Tests for the ACP Conductor — chain init, routing, passthrough, errors. + +Tests cover T9 (chain initialization), T10 (message routing), T11 (_step), +and context manager lifecycle. All tests use fake/mock proxies and connections +— NO real subprocess is spawned. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from acp.conductor import Conductor, ConductorConfig +from acp.exceptions import RequestError +from acp.proxy.protocol import Proxy + + +# --------------------------------------------------------------------------- +# Fake Proxy for testing +# --------------------------------------------------------------------------- + + +class FakeProxy: + """Fake proxy implementing the Proxy protocol.""" + + def __init__( + self, + intercepted_methods: list[str] | None = None, + successor_response: dict[str, Any] | None = None, + init_error: Exception | None = None, + successor_error: Exception | None = None, + ) -> None: + self._intercepted = intercepted_methods or [] + self._successor_response = successor_response or {"result": "ok"} + self._init_error = init_error + self._successor_error = successor_error + self.init_called = False + self.init_call_count = 0 + self.successor_calls: list[tuple[str, dict[str, Any], dict[str, Any]]] = [] + + def proxy_initialize(self) -> list[str]: + self.init_called = True + self.init_call_count += 1 + if self._init_error is not None: + raise self._init_error + return self._intercepted + + async def proxy_successor( + self, + method: str, + params: dict[str, Any], + meta: dict[str, Any], + ) -> dict[str, Any]: + self.successor_calls.append((method, params, meta)) + if self._successor_error is not None: + raise self._successor_error + return self._successor_response + + +class SuccessorFailingProxy: + """Proxy that raises during proxy_successor.""" + + def __init__(self, intercepted_methods: list[str] | None = None) -> None: + self._intercepted = intercepted_methods or ["session/prompt"] + + def proxy_initialize(self) -> list[str]: + return self._intercepted + + async def proxy_successor( + self, + method: str, + params: dict[str, Any], + meta: dict[str, Any], + ) -> dict[str, Any]: + msg = "successor failed" + raise RuntimeError(msg) + + +class RequestErrorProxy: + """Proxy that raises RequestError during proxy_successor.""" + + def __init__(self, intercepted_methods: list[str] | None = None) -> None: + self._intercepted = intercepted_methods or ["session/prompt"] + + def proxy_initialize(self) -> list[str]: + return self._intercepted + + async def proxy_successor( + self, + method: str, + params: dict[str, Any], + meta: dict[str, Any], + ) -> dict[str, Any]: + raise RequestError(-32001, "Custom proxy error", {"detail": "blocked"}) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _make_conductor( + proxy_chain: list[Any] | None = None, + client_handler: Any | None = None, +) -> Conductor: + """Create a Conductor without entering the context manager. + + Bypasses __aenter__ so no subprocess is spawned. + """ + return Conductor( + name="test_conductor", + command="echo", + args=["hello"], + proxy_chain=proxy_chain, + client_handler=client_handler, + ) + + +def _setup_initialized_conductor( + proxy_chain: list[Any] | None = None, + connection: Any | None = None, +) -> Conductor: + """Create a Conductor with internal state set up for method testing. + + Sets _connection, _intercepted_methods, _chain_initialized, and + _conductor_initialized directly — bypassing __aenter__. + """ + conductor = _make_conductor(proxy_chain=proxy_chain) + conductor._connection = connection or MagicMock() + conductor._conductor_initialized = True + + # Populate intercepted_methods from proxies + for proxy in proxy_chain or []: + intercepted = proxy.proxy_initialize() + conductor._intercepted_methods.append(intercepted) + conductor._chain_initialized = True + + return conductor + + +# --------------------------------------------------------------------------- +# ConductorConfig tests +# --------------------------------------------------------------------------- + + +def test_conductor_config_defaults() -> None: + """ConductorConfig has sensible defaults for optional fields.""" + config = ConductorConfig(command="goose") + assert config.command == "goose" + assert config.args == [] + assert config.env is None + assert config.cwd is None + + +def test_conductor_config_full() -> None: + """ConductorConfig accepts all fields.""" + config = ConductorConfig( + command="goose", + args=["acp"], + env={"FOO": "bar"}, + cwd="/tmp", + ) + assert config.command == "goose" + assert config.args == ["acp"] + assert config.env == {"FOO": "bar"} + assert config.cwd == "/tmp" + + +# --------------------------------------------------------------------------- +# Conductor __init__ tests +# --------------------------------------------------------------------------- + + +def test_conductor_init_defaults() -> None: + """Conductor initializes with correct defaults.""" + conductor = Conductor(name="test", command="echo") + assert conductor.name == "test" + assert conductor.config.command == "echo" + assert conductor.config.args == [] + assert conductor.proxy_chain == [] + assert conductor.client_handler is None + assert conductor.connection is None + assert conductor.process is None + assert not conductor.is_initialized + assert conductor._intercepted_methods == [] + assert not conductor._chain_initialized + assert conductor.agent_type == "acp" + + +def test_conductor_init_with_proxy_chain() -> None: + """Conductor stores proxy chain from constructor.""" + proxy = FakeProxy(intercepted_methods=["session/prompt"]) + conductor = Conductor(name="test", command="echo", proxy_chain=[proxy]) + assert len(conductor.proxy_chain) == 1 + assert conductor.proxy_chain[0] is proxy + + +def test_conductor_init_with_args_and_env() -> None: + """Conductor passes args, env, cwd to ConductorConfig.""" + conductor = Conductor( + name="test", + command="goose", + args=["acp", "--debug"], + env={"PATH": "/usr/bin"}, + cwd="/home/user", + ) + assert conductor.config.args == ["acp", "--debug"] + assert conductor.config.env == {"PATH": "/usr/bin"} + assert conductor.config.cwd == "/home/user" + + +def test_conductor_init_owns_handler_when_none() -> None: + """Conductor owns handler lifecycle when client_handler is None.""" + conductor = Conductor(name="test", command="echo") + assert conductor._owns_handler is True + + +def test_conductor_init_does_not_own_handler_when_provided() -> None: + """Conductor does not own handler when externally provided.""" + handler = MagicMock() + conductor = Conductor(name="test", command="echo", client_handler=handler) + assert conductor._owns_handler is False + assert conductor.client_handler is handler + + +def test_conductor_repr() -> None: + """Conductor repr includes name, command, and status.""" + conductor = Conductor(name="my_agent", command="goose") + repr_str = repr(conductor) + assert "my_agent" in repr_str + assert "goose" in repr_str + assert "not initialized" in repr_str + + +# --------------------------------------------------------------------------- +# _is_terminal tests (T9) +# --------------------------------------------------------------------------- + + +def test_is_terminal_true_when_no_proxies() -> None: + """_is_terminal returns True at index 0 when proxy chain is empty.""" + conductor = _make_conductor() + assert conductor._is_terminal(0) is True + + +def test_is_terminal_true_at_chain_end() -> None: + """_is_terminal returns True when index >= len(proxy_chain).""" + proxy = FakeProxy() + conductor = _make_conductor(proxy_chain=[proxy]) + assert conductor._is_terminal(1) is True + assert conductor._is_terminal(2) is True + + +def test_is_terminal_false_for_proxy_positions() -> None: + """_is_terminal returns False for proxy positions in the chain.""" + proxy1 = FakeProxy() + proxy2 = FakeProxy() + conductor = _make_conductor(proxy_chain=[proxy1, proxy2]) + assert conductor._is_terminal(0) is False + assert conductor._is_terminal(1) is False + assert conductor._is_terminal(2) is True + + +# --------------------------------------------------------------------------- +# _initialize_proxy tests (T9) +# --------------------------------------------------------------------------- + + +async def test_initialize_proxy_returns_intercepted_methods() -> None: + """_initialize_proxy calls proxy.proxy_initialize() and returns methods.""" + proxy = FakeProxy(intercepted_methods=["session/prompt", "session/update"]) + conductor = _make_conductor() + result = await conductor._initialize_proxy(proxy, 0) + assert proxy.init_called + assert result == ["session/prompt", "session/update"] + + +async def test_initialize_proxy_empty_intercepted_list() -> None: + """_initialize_proxy returns empty list when proxy intercepts nothing.""" + proxy = FakeProxy(intercepted_methods=[]) + conductor = _make_conductor() + result = await conductor._initialize_proxy(proxy, 0) + assert result == [] + + +# --------------------------------------------------------------------------- +# _initialize_chain tests (T9) +# --------------------------------------------------------------------------- + + +async def test_initialize_chain_zero_proxies() -> None: + """_initialize_chain with no proxies skips proxy init, goes to terminal.""" + conductor = _make_conductor() + conductor._connection = MagicMock() + + # Mock _initialize_terminal to avoid real ACP call + conductor._initialize_terminal = AsyncMock() # type: ignore[method-assign] + + await conductor._initialize_chain() + + assert conductor._chain_initialized is True + assert conductor._intercepted_methods == [] + conductor._initialize_terminal.assert_called_once() + + +async def test_initialize_chain_n_proxies_in_order() -> None: + """_initialize_chain calls proxy_initialize on each proxy in order.""" + proxy1 = FakeProxy(intercepted_methods=["session/prompt"]) + proxy2 = FakeProxy(intercepted_methods=["session/update"]) + conductor = _make_conductor(proxy_chain=[proxy1, proxy2]) + conductor._connection = MagicMock() + conductor._initialize_terminal = AsyncMock() # type: ignore[method-assign] + + await conductor._initialize_chain() + + assert proxy1.init_called + assert proxy2.init_called + assert conductor._intercepted_methods == [["session/prompt"], ["session/update"]] + assert conductor._chain_initialized is True + conductor._initialize_terminal.assert_called_once() + + +async def test_initialize_chain_proxy_crash_clears_state() -> None: + """_initialize_chain clears intercepted_methods on proxy crash.""" + proxy1 = FakeProxy(intercepted_methods=["session/prompt"]) + proxy2 = FakeProxy(init_error=RuntimeError("init failed")) + conductor = _make_conductor(proxy_chain=[proxy1, proxy2]) + conductor._connection = MagicMock() + + with pytest.raises(RuntimeError, match="init failed"): + await conductor._initialize_chain() + + assert conductor._intercepted_methods == [] + assert not conductor._chain_initialized + + +async def test_initialize_chain_terminal_crash_clears_state() -> None: + """_initialize_chain clears state when terminal init fails.""" + proxy = FakeProxy(intercepted_methods=["session/prompt"]) + conductor = _make_conductor(proxy_chain=[proxy]) + conductor._connection = MagicMock() + conductor._initialize_terminal = AsyncMock( # type: ignore[method-assign] + side_effect=RuntimeError("terminal init failed"), + ) + + with pytest.raises(RuntimeError, match="terminal init failed"): + await conductor._initialize_chain() + + assert conductor._intercepted_methods == [] + assert not conductor._chain_initialized + + +# --------------------------------------------------------------------------- +# _initialize_terminal tests (T9) +# --------------------------------------------------------------------------- + + +async def test_initialize_terminal_raises_without_connection() -> None: + """_initialize_terminal raises RuntimeError when connection is None.""" + conductor = _make_conductor() + with pytest.raises(RuntimeError, match="connection not established"): + await conductor._initialize_terminal() + + +# --------------------------------------------------------------------------- +# _should_intercept tests (T10) +# --------------------------------------------------------------------------- + + +def test_should_intercept_true_for_registered_method() -> None: + """_should_intercept returns True when a proxy intercepts the method.""" + proxy = FakeProxy(intercepted_methods=["session/prompt"]) + conductor = _setup_initialized_conductor(proxy_chain=[proxy]) + assert conductor._should_intercept("session/prompt") is True + + +def test_should_intercept_false_for_unregistered_method() -> None: + """_should_intercept returns False when no proxy intercepts the method.""" + proxy = FakeProxy(intercepted_methods=["session/prompt"]) + conductor = _setup_initialized_conductor(proxy_chain=[proxy]) + assert conductor._should_intercept("session/update") is False + + +def test_should_intercept_false_when_no_proxies() -> None: + """_should_intercept returns False when proxy chain is empty.""" + conductor = _setup_initialized_conductor() + assert conductor._should_intercept("session/prompt") is False + + +def test_should_intercept_true_when_any_proxy_intercepts() -> None: + """_should_intercept returns True if ANY proxy in the chain intercepts.""" + proxy1 = FakeProxy(intercepted_methods=["session/prompt"]) + proxy2 = FakeProxy(intercepted_methods=["session/update"]) + conductor = _setup_initialized_conductor(proxy_chain=[proxy1, proxy2]) + assert conductor._should_intercept("session/prompt") is True + assert conductor._should_intercept("session/update") is True + + +# --------------------------------------------------------------------------- +# _handle_proxy_error tests (T10) +# --------------------------------------------------------------------------- + + +async def test_handle_proxy_error_generic_exception() -> None: + """_handle_proxy_error produces JSON-RPC -32603 for generic exceptions.""" + conductor = _make_conductor() + error = ValueError("something went wrong") + result = await conductor._handle_proxy_error(error, 2) + assert "error" in result + error_obj = result["error"] + assert error_obj["code"] == -32603 + assert "Proxy 2 error" in error_obj["message"] + assert error_obj["data"]["proxyIndex"] == 2 + assert error_obj["data"]["errorType"] == "ValueError" + + +async def test_handle_proxy_error_request_error() -> None: + """_handle_proxy_error uses RequestError's code and message.""" + conductor = _make_conductor() + error = RequestError(-32001, "Custom error", {"detail": "blocked"}) + result = await conductor._handle_proxy_error(error, 0) + assert "error" in result + error_obj = result["error"] + assert error_obj["code"] == -32001 + assert "Custom error" in str(error_obj["message"]) + assert error_obj["data"] == {"detail": "blocked"} + + +# --------------------------------------------------------------------------- +# _forward_through_proxies tests (T10) +# --------------------------------------------------------------------------- + + +async def test_forward_through_proxies_calls_intercepting_only() -> None: + """_forward_through_proxies only calls proxies that intercept the method.""" + proxy1 = FakeProxy( + intercepted_methods=["session/prompt"], + successor_response={"result": "modified"}, + ) + proxy2 = FakeProxy( + intercepted_methods=["session/update"], + successor_response={"result": "ok"}, + ) + conductor = _setup_initialized_conductor(proxy_chain=[proxy1, proxy2]) + + result = await conductor._forward_through_proxies( + "session/prompt", + {"prompt": []}, + {"direction": "forward"}, + ) + + assert result == {"result": "modified"} + assert len(proxy1.successor_calls) == 1 + assert len(proxy2.successor_calls) == 0 + + +async def test_forward_through_proxies_no_interception_returns_original() -> None: + """_forward_through_proxies returns original params when no proxy intercepts.""" + proxy = FakeProxy(intercepted_methods=["session/update"]) + conductor = _setup_initialized_conductor(proxy_chain=[proxy]) + + original_params: dict[str, Any] = {"prompt": []} + result = await conductor._forward_through_proxies( + "session/prompt", + original_params, + {"direction": "forward"}, + ) + + assert result is original_params + assert len(proxy.successor_calls) == 0 + + +async def test_forward_through_proxies_error_produces_jsonrpc_error() -> None: + """_forward_through_proxies returns JSON-RPC error on proxy exception.""" + proxy = SuccessorFailingProxy(intercepted_methods=["session/prompt"]) + conductor = _setup_initialized_conductor(proxy_chain=[proxy]) + + result = await conductor._forward_through_proxies( + "session/prompt", + {"prompt": []}, + {"direction": "forward"}, + ) + + assert "error" in result + assert result["error"]["code"] == -32603 + assert "successor failed" in result["error"]["message"] + + +async def test_forward_through_proxies_request_error_uses_own_code() -> None: + """_forward_through_proxies uses RequestError's own code/message.""" + proxy = RequestErrorProxy(intercepted_methods=["session/prompt"]) + conductor = _setup_initialized_conductor(proxy_chain=[proxy]) + + result = await conductor._forward_through_proxies( + "session/prompt", + {"prompt": []}, + {"direction": "forward"}, + ) + + assert "error" in result + assert result["error"]["code"] == -32001 + assert "Custom proxy error" in str(result["error"]["message"]) + + +async def test_forward_through_proxies_multiple_intercepting() -> None: + """_forward_through_proxies chains through multiple intercepting proxies.""" + proxy1 = FakeProxy( + intercepted_methods=["session/prompt"], + successor_response={"result": "first"}, + ) + proxy2 = FakeProxy( + intercepted_methods=["session/prompt"], + successor_response={"result": "second"}, + ) + conductor = _setup_initialized_conductor(proxy_chain=[proxy1, proxy2]) + + result = await conductor._forward_through_proxies( + "session/prompt", + {"prompt": []}, + {"direction": "forward"}, + ) + + # Last proxy's response wins + assert result == {"result": "second"} + assert len(proxy1.successor_calls) == 1 + assert len(proxy2.successor_calls) == 1 + + +# --------------------------------------------------------------------------- +# _route_to_terminal tests (T10) +# --------------------------------------------------------------------------- + + +async def test_route_to_terminal_passthrough() -> None: + """_route_to_terminal sends directly when no proxy intercepts (passthrough).""" + mock_conn = MagicMock() + mock_conn.send_request = AsyncMock(return_value={"result": "response"}) + conductor = _setup_initialized_conductor( + proxy_chain=[FakeProxy(intercepted_methods=["session/update"])], + connection=mock_conn, + ) + + result = await conductor._route_to_terminal("session/prompt", {"prompt": []}) + + mock_conn.send_request.assert_called_once_with("session/prompt", {"prompt": []}) + assert result == {"result": "response"} + + +async def test_route_to_terminal_with_interception() -> None: + """_route_to_terminal forwards through proxies when they intercept.""" + mock_conn = MagicMock() + mock_conn.send_request = AsyncMock(return_value={"result": "terminal_response"}) + proxy = FakeProxy( + intercepted_methods=["session/prompt"], + successor_response={"prompt": [{"type": "text", "text": "modified"}]}, + ) + conductor = _setup_initialized_conductor( + proxy_chain=[proxy], + connection=mock_conn, + ) + + result = await conductor._route_to_terminal("session/prompt", {"prompt": []}) + + # Proxy modified params (forward), then response routed back (reverse) = 2 calls + assert len(proxy.successor_calls) == 2 + mock_conn.send_request.assert_called_once() + sent_params = mock_conn.send_request.call_args[0][1] + assert sent_params == {"prompt": [{"type": "text", "text": "modified"}]} + # Reverse routing: proxy also processes the response, returning its successor_response + assert result == {"prompt": [{"type": "text", "text": "modified"}]} + + +async def test_route_to_terminal_proxy_error_stops_propagation() -> None: + """_route_to_terminal stops when proxy returns error response.""" + mock_conn = MagicMock() + mock_conn.send_request = AsyncMock() + proxy = SuccessorFailingProxy(intercepted_methods=["session/prompt"]) + conductor = _setup_initialized_conductor( + proxy_chain=[proxy], + connection=mock_conn, + ) + + result = await conductor._route_to_terminal("session/prompt", {"prompt": []}) + + assert "error" in result + mock_conn.send_request.assert_not_called() + + +async def test_route_to_terminal_raises_without_connection() -> None: + """_route_to_terminal raises RuntimeError when connection is None.""" + conductor = _make_conductor() + with pytest.raises(RuntimeError, match="connection not established"): + await conductor._route_to_terminal("session/prompt", {}) + + +async def test_route_to_terminal_non_dict_response_wrapped() -> None: + """_route_to_terminal wraps non-dict response in {"result": ...}.""" + mock_conn = MagicMock() + mock_conn.send_request = AsyncMock(return_value="plain_string") + conductor = _setup_initialized_conductor(connection=mock_conn) + + result = await conductor._route_to_terminal("session/prompt", {"prompt": []}) + + assert result == {"result": "plain_string"} + + +# --------------------------------------------------------------------------- +# _route_message tests (T10) +# --------------------------------------------------------------------------- + + +async def test_route_message_forward_direction() -> None: + """_route_message with direction=forward calls _route_to_terminal.""" + mock_conn = MagicMock() + mock_conn.send_request = AsyncMock(return_value={"result": "ok"}) + conductor = _setup_initialized_conductor(connection=mock_conn) + + result = await conductor._route_message( + "session/prompt", + {"prompt": []}, + {"direction": "forward"}, + ) + + assert result == {"result": "ok"} + mock_conn.send_request.assert_called_once() + + +async def test_route_message_default_direction_is_forward() -> None: + """_route_message defaults to forward when direction is not specified.""" + mock_conn = MagicMock() + mock_conn.send_request = AsyncMock(return_value={"result": "ok"}) + conductor = _setup_initialized_conductor(connection=mock_conn) + + result = await conductor._route_message( + "session/prompt", + {"prompt": []}, + {}, + ) + + assert result == {"result": "ok"} + + +async def test_route_message_reverse_direction_returns_params() -> None: + """_route_message with direction=reverse returns params directly (stub).""" + conductor = _setup_initialized_conductor() + + params = {"result": "reverse_response"} + result = await conductor._route_message( + "session/prompt", + params, + {"direction": "reverse"}, + ) + + assert result == params + + +# --------------------------------------------------------------------------- +# _step property tests (T11) +# --------------------------------------------------------------------------- + + +def test_step_returns_step_with_conductor_name() -> None: + """_step property returns a Step with the Conductor's name as ID.""" + conductor = _make_conductor() + step = conductor._step + assert step is not None + assert step.label == "Conductor(test_conductor)" + + +# --------------------------------------------------------------------------- +# __aexit__ tests +# --------------------------------------------------------------------------- + + +async def test_aexit_clears_state() -> None: + """__aexit__ clears runtime state even without full init.""" + conductor = _make_conductor() + # Simulate partial initialization + conductor._intercepted_methods = [["session/prompt"]] + conductor._chain_initialized = True + conductor._conductor_initialized = True + conductor._exit_stack = None # Avoid real cleanup + + await conductor.__aexit__(None, None, None) + + assert conductor._intercepted_methods == [] + assert not conductor._chain_initialized + assert not conductor._conductor_initialized + assert conductor._connection is None + assert conductor._process is None + + +async def test_aexit_cleans_up_exit_stack() -> None: + """__aexit__ closes the exit stack if it exists.""" + conductor = _make_conductor() + mock_stack = MagicMock() + mock_stack.aclose = AsyncMock() + conductor._exit_stack = mock_stack + conductor._conductor_initialized = True + + await conductor.__aexit__(None, None, None) + + mock_stack.aclose.assert_called_once() + assert conductor._exit_stack is None + + +async def test_aexit_cleans_up_owned_handler() -> None: + """__aexit__ cleans up handler when Conductor owns it.""" + mock_handler = MagicMock() + mock_handler.cleanup = AsyncMock() + conductor = Conductor( + name="test", + command="echo", + client_handler=mock_handler, + ) + conductor._exit_stack = None + conductor._conductor_initialized = True + # Force ownership flag + conductor._owns_handler = True + + await conductor.__aexit__(None, None, None) + + mock_handler.cleanup.assert_called_once() + + +# --------------------------------------------------------------------------- +# get_stats tests (T11) +# --------------------------------------------------------------------------- + + +async def test_get_stats_empty_connections() -> None: + """get_stats returns empty MessageStats when no connections exist.""" + conductor = _make_conductor() + stats = await conductor.get_stats() + # Should return a MessageStats (empty) + assert stats is not None + + +# --------------------------------------------------------------------------- +# Properties tests +# --------------------------------------------------------------------------- + + +def test_config_property_returns_conductor_config() -> None: + """Config property returns the ConductorConfig.""" + conductor = Conductor(name="test", command="goose", args=["acp"]) + assert isinstance(conductor.config, ConductorConfig) + assert conductor.config.command == "goose" + + +def test_proxy_chain_property_returns_list() -> None: + """proxy_chain property returns the proxy list.""" + proxy = FakeProxy() + conductor = Conductor(name="test", command="echo", proxy_chain=[proxy]) + assert conductor.proxy_chain == [proxy] + + +def test_connection_property_returns_none_before_init() -> None: + """Connection property returns None before __aenter__.""" + conductor = _make_conductor() + assert conductor.connection is None + + +def test_is_initialized_property() -> None: + """is_initialized reflects _conductor_initialized state.""" + conductor = _make_conductor() + assert not conductor.is_initialized + conductor._conductor_initialized = True + assert conductor.is_initialized + + +# --------------------------------------------------------------------------- +# FakeProxy implements Proxy protocol +# --------------------------------------------------------------------------- + + +def test_fake_proxy_is_proxy() -> None: + """FakeProxy implements the Proxy protocol.""" + proxy = FakeProxy() + assert isinstance(proxy, Proxy) + + +def test_successor_failing_proxy_is_proxy() -> None: + """SuccessorFailingProxy implements the Proxy protocol.""" + proxy = SuccessorFailingProxy() + assert isinstance(proxy, Proxy) + + +def test_request_error_proxy_is_proxy() -> None: + """RequestErrorProxy implements the Proxy protocol.""" + proxy = RequestErrorProxy() + assert isinstance(proxy, Proxy) diff --git a/tests/acp/test_context_injection_proxy.py b/tests/acp/test_context_injection_proxy.py new file mode 100644 index 000000000..5ab760b40 --- /dev/null +++ b/tests/acp/test_context_injection_proxy.py @@ -0,0 +1,136 @@ +"""Tests for ContextInjectionProxy — injects AGENTS.md and skill instructions. + +Covers: AGENTS.md injection, skill instruction injection, missing file +passthrough, non-prompt method passthrough. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from acp.proxy.impls.context_injection import ContextInjectionProxy +from acp.proxy.protocol import Proxy + + +if TYPE_CHECKING: + from pathlib import Path + + +# --------------------------------------------------------------------------- +# Proxy protocol compliance +# --------------------------------------------------------------------------- + + +def test_context_injection_implements_proxy_protocol( + tmp_path: Path, +) -> None: + """ContextInjectionProxy satisfies the runtime_checkable Proxy protocol.""" + proxy = ContextInjectionProxy(agents_md_path=str(tmp_path / "AGENTS.md")) + assert isinstance(proxy, Proxy) + + +# --------------------------------------------------------------------------- +# AGENTS.md injection +# --------------------------------------------------------------------------- + + +async def test_context_injection_prepends_agents_md(tmp_path: Path) -> None: + """AGENTS.md content is prepended to session/prompt content list.""" + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text("# Project Rules\n\nBe helpful.", encoding="utf-8") + proxy = ContextInjectionProxy(agents_md_path=str(agents_md)) + params: dict[str, Any] = { + "content": [{"type": "text", "text": "hello agent"}], + } + meta: dict[str, Any] = {"response": False} + result = await proxy.proxy_successor("session/prompt", params, meta) + content = result["content"] + assert isinstance(content, list) + assert len(content) == 2 + injected = content[0] + assert injected["type"] == "text" + assert "# Project Rules" in injected["text"] + assert "Be helpful." in injected["text"] + assert content[1] == {"type": "text", "text": "hello agent"} + + +async def test_context_injection_prepends_skill_instructions( + tmp_path: Path, +) -> None: + """Skill instructions are prepended alongside AGENTS.md content.""" + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text("# Rules", encoding="utf-8") + proxy = ContextInjectionProxy( + agents_md_path=str(agents_md), + skill_instructions=["Use uv", "Write tests"], + ) + params: dict[str, Any] = { + "content": [{"type": "text", "text": "do work"}], + } + meta: dict[str, Any] = {"response": False} + result = await proxy.proxy_successor("session/prompt", params, meta) + content = result["content"] + assert len(content) == 2 + injected_text = content[0]["text"] + assert "# Rules" in injected_text + assert "Use uv" in injected_text + assert "Write tests" in injected_text + + +# --------------------------------------------------------------------------- +# Missing AGENTS.md +# --------------------------------------------------------------------------- + + +async def test_context_injection_missing_agents_md(tmp_path: Path) -> None: + """When AGENTS.md does not exist, no injection occurs.""" + proxy = ContextInjectionProxy( + agents_md_path=str(tmp_path / "nonexistent.md"), + ) + params: dict[str, Any] = { + "content": [{"type": "text", "text": "original"}], + } + meta: dict[str, Any] = {"response": False} + result = await proxy.proxy_successor("session/prompt", params, meta) + assert result["content"] == [{"type": "text", "text": "original"}] + + +# --------------------------------------------------------------------------- +# Non-prompt passthrough +# --------------------------------------------------------------------------- + + +async def test_context_injection_passthrough_non_prompt(tmp_path: Path) -> None: + """Non-prompt methods pass through unchanged.""" + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text("# Rules", encoding="utf-8") + proxy = ContextInjectionProxy(agents_md_path=str(agents_md)) + params: dict[str, Any] = {"sessionId": "abc123", "cwd": "/tmp"} + meta: dict[str, Any] = {"response": False} + result = await proxy.proxy_successor("session/new", params, meta) + assert result is params + + +async def test_context_injection_passthrough_response(tmp_path: Path) -> None: + """session/prompt responses are not injected (only requests).""" + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text("# Rules", encoding="utf-8") + proxy = ContextInjectionProxy(agents_md_path=str(agents_md)) + params: dict[str, Any] = {"result": {"text": "response"}} + meta: dict[str, Any] = {"response": True} + result = await proxy.proxy_successor("session/prompt", params, meta) + assert result is params + + +async def test_context_injection_string_content(tmp_path: Path) -> None: + """When content is a string, context is prepended as string.""" + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text("# Rules", encoding="utf-8") + proxy = ContextInjectionProxy(agents_md_path=str(agents_md)) + params: dict[str, Any] = {"content": "hello"} + meta: dict[str, Any] = {"response": False} + result = await proxy.proxy_successor("session/prompt", params, meta) + content = result["content"] + assert isinstance(content, str) + assert "# Rules" in content + assert "hello" in content diff --git a/tests/acp/test_hook_coexistence.py b/tests/acp/test_hook_coexistence.py new file mode 100644 index 000000000..9e80b3444 --- /dev/null +++ b/tests/acp/test_hook_coexistence.py @@ -0,0 +1,162 @@ +"""Tests for HookProxy coexistence with Conductor and ACPClientHandler. + +Covers: get_turn_hooks() behavior with/without HookProxy, +_maybe_auto_insert_hook_proxy(), set_hooks_enabled(). +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from acp.conductor import Conductor +from acp.proxy.impls.hook_proxy import HookProxy +from agentpool.hooks.agent_hooks import AgentHooks + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _make_agent_hooks(has_hooks: bool = True) -> MagicMock: + """Create an AsyncMock(spec=AgentHooks) with has_hooks configured.""" + mock = AsyncMock(spec=AgentHooks) + mock.has_hooks.return_value = has_hooks + return mock + + +# --------------------------------------------------------------------------- +# Conductor.get_turn_hooks() +# --------------------------------------------------------------------------- + + +def test_conductor_get_turn_hooks_returns_hooks_when_no_hook_proxy() -> None: + """When no HookProxy is active, get_turn_hooks returns the AgentHooks.""" + hooks = _make_agent_hooks(has_hooks=True) + conductor = Conductor( + name="test", + command="echo", + agent_hooks=hooks, + ) + # _has_hook_proxy defaults to False (not yet initialized) + assert conductor.has_hook_proxy is False + result = conductor.get_turn_hooks() + assert result is hooks + + +def test_conductor_get_turn_hooks_returns_none_when_hook_proxy() -> None: + """When HookProxy is active, get_turn_hooks returns None.""" + hooks = _make_agent_hooks(has_hooks=True) + conductor = Conductor( + name="test", + command="echo", + agent_hooks=hooks, + ) + # Simulate HookProxy being active + conductor._has_hook_proxy = True + assert conductor.has_hook_proxy is True + result = conductor.get_turn_hooks() + assert result is None + + +def test_conductor_get_turn_hooks_returns_none_when_no_hooks() -> None: + """When agent_hooks is None, get_turn_hooks returns None.""" + conductor = Conductor( + name="test", + command="echo", + agent_hooks=None, + ) + result = conductor.get_turn_hooks() + assert result is None + + +# --------------------------------------------------------------------------- +# Conductor._maybe_auto_insert_hook_proxy() +# --------------------------------------------------------------------------- + + +def test_conductor_auto_insert_hook_proxy() -> None: + """Agent with hooks gets HookProxy auto-inserted at position 0.""" + hooks = _make_agent_hooks(has_hooks=True) + conductor = Conductor( + name="test", + command="echo", + agent_hooks=hooks, + ) + assert len(conductor.proxy_chain) == 0 + conductor._maybe_auto_insert_hook_proxy() + assert len(conductor.proxy_chain) == 1 + assert isinstance(conductor.proxy_chain[0], HookProxy) + assert conductor.has_hook_proxy is True + + +def test_conductor_no_auto_insert_when_no_hooks() -> None: + """Agent without hooks does not get HookProxy inserted.""" + hooks = _make_agent_hooks(has_hooks=False) + conductor = Conductor( + name="test", + command="echo", + agent_hooks=hooks, + ) + conductor._maybe_auto_insert_hook_proxy() + assert len(conductor.proxy_chain) == 0 + assert conductor.has_hook_proxy is False + + +def test_conductor_no_auto_insert_when_already_present() -> None: + """When HookProxy already in chain, no duplicate is inserted.""" + hooks = _make_agent_hooks(has_hooks=True) + existing_proxy = HookProxy(hooks=[hooks]) + conductor = Conductor( + name="test", + command="echo", + agent_hooks=hooks, + proxy_chain=[existing_proxy], + ) + conductor._maybe_auto_insert_hook_proxy() + assert len(conductor.proxy_chain) == 1 + assert conductor.proxy_chain[0] is existing_proxy + assert conductor.has_hook_proxy is True + + +def test_conductor_no_auto_insert_when_agent_hooks_none() -> None: + """When agent_hooks is None, no HookProxy is inserted.""" + conductor = Conductor( + name="test", + command="echo", + agent_hooks=None, + ) + conductor._maybe_auto_insert_hook_proxy() + assert len(conductor.proxy_chain) == 0 + assert conductor.has_hook_proxy is False + + +# --------------------------------------------------------------------------- +# ACPClientHandler.set_hooks_enabled() +# --------------------------------------------------------------------------- + + +def test_client_handler_set_hooks_enabled() -> None: + """set_hooks_enabled(False) sets _hooks_enabled to False.""" + # Build a minimal mock ACPAgent and ACPState to satisfy __init__ + mock_agent = MagicMock() + mock_agent.client_env = MagicMock() + mock_agent.auto_approve = False + mock_agent.acp_permission_callback = None + mock_agent._init_request = MagicMock() + mock_agent._init_request.client_capabilities = None + mock_agent.state_updated = MagicMock() + mock_agent.state_updated.emit = AsyncMock() + + mock_state = MagicMock() + + from agentpool.agents.acp_agent.client_handler import ACPClientHandler + + handler = ACPClientHandler(agent=mock_agent, state=mock_state) + assert handler._hooks_enabled is True + + handler.set_hooks_enabled(False) + assert handler._hooks_enabled is False + + handler.set_hooks_enabled(True) + assert handler._hooks_enabled is True diff --git a/tests/acp/test_hook_proxy.py b/tests/acp/test_hook_proxy.py new file mode 100644 index 000000000..806951225 --- /dev/null +++ b/tests/acp/test_hook_proxy.py @@ -0,0 +1,202 @@ +"""Tests for HookProxy — wraps AgentHooks as a Proxy in the ACP chain. + +Covers: Proxy protocol compliance, pre_turn deny/additional_context, +pre_tool_use deny, post_tool_use modified_output, post_turn, passthrough. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from acp.proxy.impls.hook_proxy import HookProxy +from acp.proxy.protocol import Proxy +from agentpool.hooks.agent_hooks import AgentHooks + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _make_hooks( + *, + has_hooks: bool = True, + pre_turn_result: dict[str, Any] | None = None, + post_turn_result: dict[str, Any] | None = None, + pre_tool_result: dict[str, Any] | None = None, + post_tool_result: dict[str, Any] | None = None, +) -> MagicMock: + """Create an AsyncMock(spec=AgentHooks) with configured return values.""" + mock = AsyncMock(spec=AgentHooks) + mock.has_hooks.return_value = has_hooks + mock.run_pre_turn_hooks.return_value = pre_turn_result or {"decision": "allow"} + mock.run_post_turn_hooks.return_value = post_turn_result or {"decision": "allow"} + mock.run_pre_tool_hooks.return_value = pre_tool_result or {"decision": "allow"} + mock.run_post_tool_hooks.return_value = post_tool_result or {"decision": "allow"} + return mock + + +# --------------------------------------------------------------------------- +# Proxy protocol compliance +# --------------------------------------------------------------------------- + + +def test_hook_proxy_implements_proxy_protocol() -> None: + """HookProxy satisfies the runtime_checkable Proxy protocol.""" + hooks = _make_hooks() + proxy = HookProxy(hooks=[hooks]) + assert isinstance(proxy, Proxy) + + +def test_hook_proxy_proxy_initialize_returns_methods() -> None: + """proxy_initialize returns ['session/prompt', 'session/update'].""" + hooks = _make_hooks() + proxy = HookProxy(hooks=[hooks]) + result = proxy.proxy_initialize() + assert result == ["session/prompt", "session/update"] + + +# --------------------------------------------------------------------------- +# pre_turn (session/prompt request) +# --------------------------------------------------------------------------- + + +async def test_pre_turn_deny_blocks() -> None: + """When pre_turn hook returns deny, params replaced with error response.""" + hooks = _make_hooks(pre_turn_result={"decision": "deny", "reason": "blocked by policy"}) + proxy = HookProxy(hooks=[hooks]) + params: dict[str, Any] = {"content": [{"type": "text", "text": "hello"}]} + meta: dict[str, Any] = {"agent_name": "test_agent", "response": False} + result = await proxy.proxy_successor("session/prompt", params, meta) + assert "error" in result + assert result["error"]["code"] == -32603 + assert "Blocked by pre_turn hook" in result["error"]["message"] + assert result["error"]["data"]["reason"] == "blocked by policy" + + +async def test_pre_turn_additional_context_injected() -> None: + """When pre_turn hook returns additional_context, it is prepended to content.""" + hooks = _make_hooks( + pre_turn_result={"decision": "allow", "additional_context": "extra context here"}, + ) + proxy = HookProxy(hooks=[hooks]) + params: dict[str, Any] = { + "content": [{"type": "text", "text": "original prompt"}], + } + meta: dict[str, Any] = {"agent_name": "test_agent", "response": False} + result = await proxy.proxy_successor("session/prompt", params, meta) + content = result["content"] + assert isinstance(content, list) + assert len(content) == 2 + assert content[0] == {"type": "text", "text": "extra context here"} + assert content[1] == {"type": "text", "text": "original prompt"} + + +# --------------------------------------------------------------------------- +# pre_tool_use (session/update ToolCallStart) +# --------------------------------------------------------------------------- + + +async def test_pre_tool_use_deny_blocks() -> None: + """When pre_tool_use hook returns deny, params replaced with error response.""" + hooks = _make_hooks( + pre_tool_result={"decision": "deny", "reason": "tool not allowed"}, + ) + proxy = HookProxy(hooks=[hooks]) + params: dict[str, Any] = { + "update": { + "type": "tool_call_start", + "tool_call_id": "bash_tool", + "raw_input": {"command": "rm -rf /"}, + }, + } + meta: dict[str, Any] = {"agent_name": "test_agent"} + result = await proxy.proxy_successor("session/update", params, meta) + assert "error" in result + assert result["error"]["code"] == -32603 + assert "bash_tool" in result["error"]["message"] + + +# --------------------------------------------------------------------------- +# post_tool_use (session/update ToolCallComplete) +# --------------------------------------------------------------------------- + + +async def test_post_tool_use_modifies_output() -> None: + """When post_tool_use hook returns modified_output, raw_output is replaced.""" + hooks = _make_hooks( + post_tool_result={ + "decision": "allow", + "modified_output": {"sanitized": "clean output"}, + }, + ) + proxy = HookProxy(hooks=[hooks]) + params: dict[str, Any] = { + "update": { + "type": "tool_call_complete", + "tool_call_id": "read_tool", + "raw_input": {"path": "/etc/passwd"}, + "raw_output": {"content": "secret data"}, + }, + } + meta: dict[str, Any] = {"agent_name": "test_agent"} + result = await proxy.proxy_successor("session/update", params, meta) + update = result["update"] + assert update["raw_output"] == {"sanitized": "clean output"} + + +# --------------------------------------------------------------------------- +# post_turn (session/prompt response) +# --------------------------------------------------------------------------- + + +async def test_post_turn_on_response() -> None: + """When session/prompt response arrives, post_turn hooks are called.""" + hooks = _make_hooks( + post_turn_result={ + "decision": "allow", + "modified_output": {"result": {"text": "modified response"}}, + }, + ) + proxy = HookProxy(hooks=[hooks]) + params: dict[str, Any] = {"result": {"text": "original response"}} + meta: dict[str, Any] = { + "agent_name": "test_agent", + "response": True, + "prompt": "what is 2+2", + "duration_ms": 150.0, + } + result = await proxy.proxy_successor("session/prompt", params, meta) + hooks.run_post_turn_hooks.assert_awaited_once() + call_kwargs = hooks.run_post_turn_hooks.call_args + assert call_kwargs.kwargs["agent_name"] == "test_agent" + assert call_kwargs.kwargs["prompt"] == "what is 2+2" + assert call_kwargs.kwargs["duration_ms"] == 150.0 + assert result == {"result": {"text": "modified response"}} + + +# --------------------------------------------------------------------------- +# Passthrough +# --------------------------------------------------------------------------- + + +async def test_passthrough_no_hooks() -> None: + """With empty hooks list, params are returned unchanged.""" + proxy = HookProxy(hooks=[]) + params: dict[str, Any] = {"content": [{"type": "text", "text": "hello"}]} + meta: dict[str, Any] = {"agent_name": "test_agent", "response": False} + result = await proxy.proxy_successor("session/prompt", params, meta) + assert result is params + + +async def test_passthrough_unrecognized_method() -> None: + """Unrecognized methods are passed through unchanged.""" + hooks = _make_hooks() + proxy = HookProxy(hooks=[hooks]) + params: dict[str, Any] = {"some_key": "some_value"} + meta: dict[str, Any] = {"agent_name": "test_agent"} + result = await proxy.proxy_successor("session/new", params, meta) + assert result is params + hooks.run_pre_turn_hooks.assert_not_awaited() + hooks.run_post_turn_hooks.assert_not_awaited() diff --git a/tests/acp/test_proxy_protocol.py b/tests/acp/test_proxy_protocol.py new file mode 100644 index 000000000..b3aeefd9a --- /dev/null +++ b/tests/acp/test_proxy_protocol.py @@ -0,0 +1,296 @@ +"""Tests for the ACP proxy chain protocol package. + +Covers: Proxy protocol (runtime_checkable, isinstance), constants, +ProxySideConnection dispatch and forwarding. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from acp.proxy import PROXY_INITIALIZE, PROXY_SUCCESSOR, Proxy, ProxySideConnection +from acp.proxy.constants import PROXY_INITIALIZE as CONST_INIT, PROXY_SUCCESSOR as CONST_SUCC + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + + +def test_proxy_initialize_constant() -> None: + """PROXY_INITIALIZE constant matches expected wire method name.""" + assert PROXY_INITIALIZE == "proxy/initialize" + assert CONST_INIT == "proxy/initialize" + + +def test_proxy_successor_constant() -> None: + """PROXY_SUCCESSOR constant matches expected wire method name.""" + assert PROXY_SUCCESSOR == "proxy/successor" + assert CONST_SUCC == "proxy/successor" + + +# --------------------------------------------------------------------------- +# Fake Proxy implementations for testing +# --------------------------------------------------------------------------- + + +class FakeProxy: + """Fake proxy implementing the Proxy protocol.""" + + def __init__( + self, + intercepted_methods: list[str] | None = None, + successor_response: dict[str, Any] | None = None, + ) -> None: + self._intercepted = intercepted_methods or [] + self._successor_response = successor_response or {"result": "ok"} + self.init_called = False + self.successor_calls: list[tuple[str, dict[str, Any], dict[str, Any]]] = [] + + def proxy_initialize(self) -> list[str]: + self.init_called = True + return self._intercepted + + async def proxy_successor( + self, + method: str, + params: dict[str, Any], + meta: dict[str, Any], + ) -> dict[str, Any]: + self.successor_calls.append((method, params, meta)) + return self._successor_response + + +class FailingProxy: + """Proxy that raises during proxy_initialize.""" + + def proxy_initialize(self) -> list[str]: + msg = "init failed" + raise RuntimeError(msg) + + async def proxy_successor( + self, + method: str, + params: dict[str, Any], + meta: dict[str, Any], + ) -> dict[str, Any]: + msg = "should not reach" + raise RuntimeError(msg) + + +class SuccessorFailingProxy: + """Proxy that raises during proxy_successor.""" + + def __init__(self, intercepted_methods: list[str] | None = None) -> None: + self._intercepted = intercepted_methods or ["session/prompt"] + + def proxy_initialize(self) -> list[str]: + return self._intercepted + + async def proxy_successor( + self, + method: str, + params: dict[str, Any], + meta: dict[str, Any], + ) -> dict[str, Any]: + msg = "successor failed" + raise RuntimeError(msg) + + +# --------------------------------------------------------------------------- +# Proxy protocol tests +# --------------------------------------------------------------------------- + + +def test_proxy_protocol_is_runtime_checkable() -> None: + """Proxy protocol supports isinstance checks (runtime_checkable).""" + proxy = FakeProxy() + assert isinstance(proxy, Proxy) + + +def test_proxy_protocol_rejects_non_implementing_class() -> None: + """Objects not implementing Proxy methods fail isinstance.""" + assert not isinstance(42, Proxy) + assert not isinstance("hello", Proxy) + assert not isinstance(object(), Proxy) + + +def test_proxy_protocol_rejects_partial_implementation() -> None: + """Class with only proxy_initialize (missing proxy_successor) is not a Proxy.""" + + class PartialProxy: + def proxy_initialize(self) -> list[str]: + return [] + + assert not isinstance(PartialProxy(), Proxy) + + +def test_fake_proxy_proxy_initialize_returns_list() -> None: + """proxy_initialize returns list[str] of intercepted methods.""" + proxy = FakeProxy(intercepted_methods=["session/prompt", "session/update"]) + result = proxy.proxy_initialize() + assert isinstance(result, list) + assert all(isinstance(m, str) for m in result) + assert result == ["session/prompt", "session/update"] + + +def test_fake_proxy_proxy_initialize_empty_list() -> None: + """proxy_initialize can return empty list (no interception).""" + proxy = FakeProxy(intercepted_methods=[]) + result = proxy.proxy_initialize() + assert result == [] + + +async def test_fake_proxy_proxy_successor_returns_dict() -> None: + """proxy_successor returns dict[str, Any] response.""" + proxy = FakeProxy(successor_response={"result": {"text": "hello"}}) + result = await proxy.proxy_successor( + "session/prompt", + {"prompt": []}, + {"direction": "forward"}, + ) + assert isinstance(result, dict) + assert result == {"result": {"text": "hello"}} + + +async def test_fake_proxy_proxy_successor_records_calls() -> None: + """proxy_successor records all calls for inspection.""" + proxy = FakeProxy() + await proxy.proxy_successor("session/prompt", {"key": "val"}, {"meta": "data"}) + assert len(proxy.successor_calls) == 1 + method, params, meta = proxy.successor_calls[0] + assert method == "session/prompt" + assert params == {"key": "val"} + assert meta == {"meta": "data"} + + +# --------------------------------------------------------------------------- +# ProxySideConnection tests +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_connection() -> MagicMock: + """Create a mock Connection for ProxySideConnection.""" + conn = MagicMock() + conn.send_request = AsyncMock(return_value={"result": "forwarded"}) + conn.send_notification = AsyncMock() + conn.close = AsyncMock() + return conn + + +@pytest.fixture +def fake_proxy() -> FakeProxy: + """Create a FakeProxy for testing.""" + return FakeProxy( + intercepted_methods=["session/prompt"], + successor_response={"result": "proxied"}, + ) + + +async def test_proxy_side_connection_handle_initialize( + mock_connection: MagicMock, + fake_proxy: FakeProxy, +) -> None: + """handle_proxy_method dispatches proxy/initialize correctly.""" + psc = ProxySideConnection(mock_connection, fake_proxy) + result = await psc.handle_proxy_method(PROXY_INITIALIZE, {}) + assert fake_proxy.init_called + assert result == {"intercepted_methods": ["session/prompt"]} + + +async def test_proxy_side_connection_handle_successor( + mock_connection: MagicMock, + fake_proxy: FakeProxy, +) -> None: + """handle_proxy_method dispatches proxy/successor correctly.""" + psc = ProxySideConnection(mock_connection, fake_proxy) + params: dict[str, Any] = { + "method": "session/prompt", + "prompt": [], + "_meta": {"direction": "forward"}, + } + result = await psc.handle_proxy_method(PROXY_SUCCESSOR, params) + assert result == {"result": "proxied"} + assert len(fake_proxy.successor_calls) == 1 + method, _p, meta = fake_proxy.successor_calls[0] + assert method == "session/prompt" + assert meta == {"direction": "forward"} + + +async def test_proxy_side_connection_handle_unknown_method( + mock_connection: MagicMock, + fake_proxy: FakeProxy, +) -> None: + """handle_proxy_method raises ValueError for unknown methods.""" + psc = ProxySideConnection(mock_connection, fake_proxy) + with pytest.raises(ValueError, match="Unknown proxy method"): + await psc.handle_proxy_method("unknown/method", {}) + + +async def test_proxy_side_connection_send_request_forwards( + mock_connection: MagicMock, + fake_proxy: FakeProxy, +) -> None: + """send_request forwards to wrapped connection.""" + psc = ProxySideConnection(mock_connection, fake_proxy) + result = await psc.send_request("session/prompt", {"key": "val"}) + mock_connection.send_request.assert_called_once_with("session/prompt", {"key": "val"}) + assert result == {"result": "forwarded"} + + +async def test_proxy_side_connection_send_request_default_params( + mock_connection: MagicMock, + fake_proxy: FakeProxy, +) -> None: + """send_request uses empty dict when params is None.""" + psc = ProxySideConnection(mock_connection, fake_proxy) + await psc.send_request("initialize") + mock_connection.send_request.assert_called_once_with("initialize", {}) + + +async def test_proxy_side_connection_send_notification_forwards( + mock_connection: MagicMock, + fake_proxy: FakeProxy, +) -> None: + """send_notification forwards to wrapped connection.""" + psc = ProxySideConnection(mock_connection, fake_proxy) + await psc.send_notification("session/update", {"key": "val"}) + mock_connection.send_notification.assert_called_once_with("session/update", {"key": "val"}) + + +async def test_proxy_side_connection_send_notification_default_params( + mock_connection: MagicMock, + fake_proxy: FakeProxy, +) -> None: + """send_notification uses empty dict when params is None.""" + psc = ProxySideConnection(mock_connection, fake_proxy) + await psc.send_notification("session/update") + mock_connection.send_notification.assert_called_once_with("session/update", {}) + + +async def test_proxy_side_connection_close( + mock_connection: MagicMock, + fake_proxy: FakeProxy, +) -> None: + """Close delegates to wrapped connection.""" + psc = ProxySideConnection(mock_connection, fake_proxy) + await psc.close() + mock_connection.close.assert_called_once() + + +async def test_proxy_side_connection_successor_without_meta( + mock_connection: MagicMock, +) -> None: + """handle_proxy_method for successor handles missing _meta gracefully.""" + proxy = FakeProxy(successor_response={"result": "ok"}) + psc = ProxySideConnection(mock_connection, proxy) + params: dict[str, Any] = {"method": "session/prompt", "prompt": []} + result = await psc.handle_proxy_method(PROXY_SUCCESSOR, params) + assert result == {"result": "ok"} + assert len(proxy.successor_calls) == 1 + _, _, meta = proxy.successor_calls[0] + assert meta == {} diff --git a/tests/acp/test_tool_provider_proxy.py b/tests/acp/test_tool_provider_proxy.py new file mode 100644 index 000000000..98e128304 --- /dev/null +++ b/tests/acp/test_tool_provider_proxy.py @@ -0,0 +1,64 @@ +"""Tests for ToolProviderProxy — experimental passthrough proxy for MCP-over-ACP. + +Covers: passthrough behavior, registry registration, proxy_initialize. +""" + +from __future__ import annotations + +from typing import Any + +from acp.proxy.impls.base import default_registry +from acp.proxy.impls.tool_provider import ToolProviderProxy +from acp.proxy.protocol import Proxy + + +# --------------------------------------------------------------------------- +# Passthrough +# --------------------------------------------------------------------------- + + +async def test_tool_provider_passthrough() -> None: + """All methods pass through unchanged (experimental stub).""" + proxy = ToolProviderProxy() + params: dict[str, Any] = {"content": [{"type": "text", "text": "hello"}]} + meta: dict[str, Any] = {"response": False} + result = await proxy.proxy_successor("session/prompt", params, meta) + assert result is params + + +async def test_tool_provider_passthrough_any_method() -> None: + """ToolProviderProxy passes through any method, not just session/prompt.""" + proxy = ToolProviderProxy() + params: dict[str, Any] = {"sessionId": "abc"} + meta: dict[str, Any] = {} + result = await proxy.proxy_successor("session/new", params, meta) + assert result is params + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + + +def test_tool_provider_registered_in_registry() -> None: + """default_registry has 'tool_provider' registered.""" + assert "tool_provider" in default_registry + assert default_registry.is_registered("tool_provider") + + +# --------------------------------------------------------------------------- +# Proxy protocol + initialize +# --------------------------------------------------------------------------- + + +def test_tool_provider_proxy_initialize() -> None: + """proxy_initialize returns ['session/prompt'].""" + proxy = ToolProviderProxy() + result = proxy.proxy_initialize() + assert result == ["session/prompt"] + + +def test_tool_provider_implements_proxy_protocol() -> None: + """ToolProviderProxy satisfies the runtime_checkable Proxy protocol.""" + proxy = ToolProviderProxy() + assert isinstance(proxy, Proxy) diff --git a/tests/agents/acp_agent/test_acp_agent_load_session.py b/tests/agents/acp_agent/test_acp_agent_load_session.py index 8ab523429..6b6fad0ba 100644 --- a/tests/agents/acp_agent/test_acp_agent_load_session.py +++ b/tests/agents/acp_agent/test_acp_agent_load_session.py @@ -15,7 +15,7 @@ UserMessageChunk, ) from agentpool.agents.acp_agent import ACPAgent -from agentpool.agents.acp_agent.session_state import ACPSessionState +from agentpool.agents.acp_agent.session_state import ACPState from agentpool.sessions.models import SessionData @@ -31,8 +31,8 @@ def mock_api(): @pytest.fixture def mock_state(): - """Create an ACPSessionState for testing.""" - return ACPSessionState(session_id="") + """Create an ACPState for testing.""" + return ACPState(session_id="") @pytest.fixture @@ -90,8 +90,8 @@ async def test_load_session_converts_updates_to_chat_messages(acp_agent, mock_ap # Add a mock update to the state during load async def side_effect(*args, **kwargs): - mock_state.add_update(UserMessageChunk.text("Hello from history")) - mock_state.add_update(AgentMessageChunk.text("Agent response")) + mock_state._load_updates.append(UserMessageChunk.text("Hello from history")) + mock_state._load_updates.append(AgentMessageChunk.text("Agent response")) return LoadSessionResponse() mock_api.load_session = AsyncMock(side_effect=side_effect) @@ -213,7 +213,7 @@ async def test_load_session_clears_existing_chat_messages(acp_agent, mock_api, m acp_agent.conversation.chat_messages.append(existing_msg) async def side_effect(*args, **kwargs): - mock_state.add_update(UserMessageChunk.text("Loaded message")) + mock_state._load_updates.append(UserMessageChunk.text("Loaded message")) return LoadSessionResponse() mock_api.load_session = AsyncMock(side_effect=side_effect) diff --git a/tests/agents/acp_agent/test_acp_turn_hooks.py b/tests/agents/acp_agent/test_acp_turn_hooks.py index 13e28f953..62892ec9a 100644 --- a/tests/agents/acp_agent/test_acp_turn_hooks.py +++ b/tests/agents/acp_agent/test_acp_turn_hooks.py @@ -13,7 +13,6 @@ from acp.schema import ( AgentMessageChunk, - PromptResponse, TextContentBlock, ToolCallProgress, ToolCallStart, @@ -71,15 +70,19 @@ def __init__( self._updates = updates or [] self._messages = messages or [] self.prompt_calls: list[tuple[str, list[Any]]] = [] + self._stop_reason: str | None = "end_turn" - async def prompt(self, session_id: str, content: list[Any]) -> PromptResponse: + async def prompt(self, session_id: str, content: list[Any]) -> None: self.prompt_calls.append((session_id, content)) - return PromptResponse(stop_reason="end_turn") - async def stream_events(self, response: PromptResponse) -> Any: + async def stream_events(self) -> Any: for update in self._updates: yield update + @property + def stop_reason(self) -> str | None: + return self._stop_reason + async def get_messages(self, session_id: str) -> list[Any]: return list(self._messages) diff --git a/tests/agents/acp_agent/test_adapter.py b/tests/agents/acp_agent/test_adapter.py new file mode 100644 index 000000000..309594ffd --- /dev/null +++ b/tests/agents/acp_agent/test_adapter.py @@ -0,0 +1,214 @@ +"""Unit tests for ACPClientAdapter. + +Tests the adapter that bridges the blocking ``ACPAgentAPI.prompt()`` to the +non-blocking ``ACPClientProtocol`` interface expected by ``ACPTurn``. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from acp.schema import ( + AgentMessageChunk, + PromptResponse, + TextContentBlock, + TurnCompleteUpdate, +) +from agentpool.agents.acp_agent.adapter import ACPClientAdapter + + +# --------------------------------------------------------------------------- +# Mock ACPAgentAPI +# --------------------------------------------------------------------------- + + +class MockACPAgentAPI: + """Mock ACPAgentAPI for testing ACPClientAdapter.""" + + def __init__( + self, + *, + response: PromptResponse | None = None, + error: Exception | None = None, + delay: float = 0.0, + ) -> None: + self._response = response or PromptResponse(stop_reason="end_turn") + self._error = error + self._delay = delay + self.prompt_calls: list[tuple[str, list[Any]]] = [] + + async def prompt(self, session_id: str, content: list[Any]) -> PromptResponse: + self.prompt_calls.append((session_id, content)) + if self._delay: + await asyncio.sleep(self._delay) + if self._error: + raise self._error + return self._response + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _text_update(text: str) -> AgentMessageChunk: + return AgentMessageChunk(content=TextContentBlock(text=text)) + + +def _make_queue(updates: list[Any] | None = None) -> asyncio.Queue[Any]: + """Create a queue pre-loaded with updates.""" + queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=1000) + for u in updates or []: + queue.put_nowait(u) + return queue + + +# --------------------------------------------------------------------------- +# Happy path tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +async def test_prompt_launches_background_task_and_returns_none() -> None: + """Given a MockACPAgentAPI, prompt() returns None immediately and launches a task.""" + api = MockACPAgentAPI() + queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=1000) + adapter = ACPClientAdapter(api, queue) + + result = await adapter.prompt("session-1", [TextContentBlock(text="hello")]) + + assert result is None + assert adapter._prompt_task is not None + assert not adapter._prompt_task.done() + + # Wait for the background task to complete + await adapter._prompt_task + assert len(api.prompt_calls) == 1 + assert api.prompt_calls[0][0] == "session-1" + + +@pytest.mark.unit +async def test_stream_events_yields_items_in_order() -> None: + """Given a queue with updates, stream_events() yields them in order.""" + updates = [_text_update("first"), _text_update("second"), _text_update("third")] + api = MockACPAgentAPI() + queue = _make_queue(updates) + adapter = ACPClientAdapter(api, queue) + + await adapter.prompt("session-1", []) + # Allow background task to complete + await asyncio.sleep(0.01) + + yielded = [item async for item in adapter.stream_events()] + + assert len(yielded) == 3 + assert yielded[0] is updates[0] + assert yielded[1] is updates[1] + assert yielded[2] is updates[2] + + +@pytest.mark.unit +async def test_stop_reason_returns_correct_value_after_completion() -> None: + """Given completed streaming, stop_reason returns the PromptResponse's stop_reason.""" + api = MockACPAgentAPI(response=PromptResponse(stop_reason="max_tokens")) + queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=1000) + adapter = ACPClientAdapter(api, queue) + + await adapter.prompt("session-1", []) + await asyncio.sleep(0.01) + async for _item in adapter.stream_events(): + pass + + assert adapter.stop_reason == "max_tokens" + + +@pytest.mark.unit +async def test_get_messages_returns_collected_updates() -> None: + """Given completed streaming, get_messages() returns all yielded updates.""" + updates = [_text_update("a"), _text_update("b"), TurnCompleteUpdate()] + api = MockACPAgentAPI() + queue = _make_queue(updates) + adapter = ACPClientAdapter(api, queue) + + await adapter.prompt("session-1", []) + await asyncio.sleep(0.01) + async for _item in adapter.stream_events(): + pass + + messages = await adapter.get_messages("session-1") + assert len(messages) == 3 + assert messages[0] is updates[0] + assert messages[1] is updates[1] + assert messages[2] is updates[2] + + +# --------------------------------------------------------------------------- +# Failure path tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +async def test_concurrent_prompt_raises_runtime_error() -> None: + """Given a prompt already in progress, second prompt() raises RuntimeError.""" + api = MockACPAgentAPI(delay=0.1) + queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=1000) + adapter = ACPClientAdapter(api, queue) + + await adapter.prompt("session-1", []) + + with pytest.raises(RuntimeError, match="Prompt already in progress"): + await adapter.prompt("session-1", []) + + # Cleanup + await asyncio.sleep(0.2) + + +@pytest.mark.unit +async def test_stop_reason_before_completion_raises_runtime_error() -> None: + """Given streaming not complete, stop_reason raises RuntimeError.""" + api = MockACPAgentAPI(delay=0.1) + queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=1000) + adapter = ACPClientAdapter(api, queue) + + # Before prompt + with pytest.raises(RuntimeError, match="stop_reason not available"): + _ = adapter.stop_reason + + await adapter.prompt("session-1", []) + + # After prompt but before completion + with pytest.raises(RuntimeError, match="stop_reason not available"): + _ = adapter.stop_reason + + await asyncio.sleep(0.2) + + +@pytest.mark.unit +async def test_background_task_error_propagates_through_stream_events() -> None: + """Given api.prompt() raises, stream_events() propagates the error.""" + error = RuntimeError("API connection lost") + api = MockACPAgentAPI(error=error) + queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=1000) + adapter = ACPClientAdapter(api, queue) + + await adapter.prompt("session-1", []) + + # Background task will fail; stream_events should propagate the error + with pytest.raises(RuntimeError, match="API connection lost"): + async for _item in adapter.stream_events(): + pass + + +@pytest.mark.unit +async def test_stream_events_without_prompt_raises_runtime_error() -> None: + """Given no prompt() called, stream_events() raises RuntimeError.""" + api = MockACPAgentAPI() + queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=1000) + adapter = ACPClientAdapter(api, queue) + + with pytest.raises(RuntimeError, match="No prompt in progress"): + async for _item in adapter.stream_events(): + pass diff --git a/tests/agents/acp_agent/test_adapter_proxy_routing.py b/tests/agents/acp_agent/test_adapter_proxy_routing.py new file mode 100644 index 000000000..f295626db --- /dev/null +++ b/tests/agents/acp_agent/test_adapter_proxy_routing.py @@ -0,0 +1,124 @@ +"""Tests for proxy chain routing through ACPClientAdapter. + +TDD tests for the critical proxy chain bypass fix. +When conductor is present, prompt() must route through the proxy chain +via conductor._route_to_terminal(), NOT through api.prompt() directly. +""" + +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from agentpool.agents.acp_agent.adapter import ACPClientAdapter + + +# --------------------------------------------------------------------------- +# Test 1: prompt() routes through conductor when conductor is present +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_adapter_prompt_routes_through_conductor() -> None: + """When conductor is provided, prompt() routes through conductor._route_to_terminal.""" + conductor = MagicMock() + conductor._route_to_terminal = AsyncMock(return_value={"result": {"stopReason": "end_turn"}}) + conductor._should_intercept = MagicMock(return_value=True) + + api = MagicMock() + api.prompt = AsyncMock() + + queue: asyncio.Queue[Any] = asyncio.Queue() + adapter = ACPClientAdapter(api=api, notification_source=queue, conductor=conductor) + + await adapter.prompt("session-1", [{"type": "text", "text": "hello"}]) + + # Wait for background task to complete + assert adapter._prompt_task is not None + await adapter._prompt_task + + # Conductor should be called with session/prompt method + conductor._route_to_terminal.assert_called_once() + call_args = conductor._route_to_terminal.call_args + assert call_args[0][0] == "session/prompt" # method name + + # api.prompt should NOT be called — proxy chain handles routing + api.prompt.assert_not_called() + + +# --------------------------------------------------------------------------- +# Test 2: prompt() falls back to api.prompt when no conductor +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_adapter_prompt_falls_back_to_api_without_conductor() -> None: + """Without conductor, prompt() calls api.prompt directly (backward compat).""" + from acp.schema import PromptResponse + + api = MagicMock() + api.prompt = AsyncMock(return_value=PromptResponse(stop_reason="end_turn")) + + queue: asyncio.Queue[Any] = asyncio.Queue() + adapter = ACPClientAdapter(api=api, notification_source=queue) + + await adapter.prompt("session-1", [{"type": "text", "text": "hello"}]) + + # Wait for background task + assert adapter._prompt_task is not None + await adapter._prompt_task + + api.prompt.assert_called_once() + + +# --------------------------------------------------------------------------- +# Test 3: stop_reason extracted from conductor response +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_adapter_stop_reason_from_conductor_response() -> None: + """stop_reason is extracted from conductor's route_to_terminal response.""" + conductor = MagicMock() + conductor._route_to_terminal = AsyncMock(return_value={"result": {"stopReason": "end_turn"}}) + conductor._should_intercept = MagicMock(return_value=True) + + api = MagicMock() + queue: asyncio.Queue[Any] = asyncio.Queue() + adapter = ACPClientAdapter(api=api, notification_source=queue, conductor=conductor) + + await adapter.prompt("session-1", []) + assert adapter._prompt_task is not None + await adapter._prompt_task + + assert adapter.stop_reason == "end_turn" + + +# --------------------------------------------------------------------------- +# Test 4: prompt() routes through api when conductor doesn't intercept +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_adapter_prompt_falls_back_when_conductor_doesnt_intercept() -> None: + """When conductor exists but doesn't intercept session/prompt, use api.prompt.""" + from acp.schema import PromptResponse + + conductor = MagicMock() + conductor._should_intercept = MagicMock(return_value=False) + + api = MagicMock() + api.prompt = AsyncMock(return_value=PromptResponse(stop_reason="end_turn")) + + queue: asyncio.Queue[Any] = asyncio.Queue() + adapter = ACPClientAdapter(api=api, notification_source=queue, conductor=conductor) + + await adapter.prompt("session-1", []) + assert adapter._prompt_task is not None + await adapter._prompt_task + + # api.prompt called because conductor doesn't intercept + api.prompt.assert_called_once() diff --git a/tests/agents/acp_agent/test_conductor_integration.py b/tests/agents/acp_agent/test_conductor_integration.py new file mode 100644 index 000000000..7655571df --- /dev/null +++ b/tests/agents/acp_agent/test_conductor_integration.py @@ -0,0 +1,368 @@ +"""Phase 3 integration tests for ACPAgent + Conductor. + +Tests backward compatibility (zero-proxy), proxy chain wiring, and +multi-turn hook firing through the Conductor integration path. + +All tests use mocks — no real subprocess is spawned. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from acp import InitializeRequest +from acp.schema import ( + AgentMessageChunk, + TextContentBlock, + TurnCompleteUpdate, +) +from agentpool.agents.acp_agent import ACPAgent +from agentpool.agents.acp_agent.turn import ACPTurn +from agentpool.agents.context import AgentRunContext +from agentpool.agents.events import ( + PartDeltaEvent, + StreamCompleteEvent, +) +from agentpool.hooks import AgentHooks, CallableHook, HookResult + + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + +# --------------------------------------------------------------------------- +# Mock ACP client (reused pattern from test_turn_integration.py) +# --------------------------------------------------------------------------- + + +class MockACPClient: + """Mock ACP client implementing ACPClientProtocol for testing.""" + + def __init__( + self, + *, + updates: list[Any] | None = None, + messages: list[Any] | None = None, + prompt_error: Exception | None = None, + ) -> None: + self._updates = updates or [] + self._messages = messages or [] + self._prompt_error = prompt_error + self.prompt_calls: list[tuple[str, list[Any]]] = [] + self._stop_reason: str | None = "end_turn" + + async def prompt(self, session_id: str, content: list[Any]) -> None: + self.prompt_calls.append((session_id, content)) + if self._prompt_error: + raise self._prompt_error + + async def stream_events(self) -> AsyncIterator[Any]: + for update in self._updates: + yield update + + @property + def stop_reason(self) -> str | None: + return self._stop_reason + + async def get_messages(self, session_id: str) -> list[Any]: + return list(self._messages) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _text_update(text: str) -> AgentMessageChunk: + return AgentMessageChunk(content=TextContentBlock(text=text)) + + +def _make_run_ctx(session_id: str = "conductor-test-session") -> AgentRunContext: + return AgentRunContext(session_id=session_id) + + +def _make_acp_agent( + *, + proxy_chain: list[Any] | None = None, +) -> ACPAgent[None]: + """Create an ACPAgent without entering its context manager.""" + init_request = MagicMock(spec=InitializeRequest) + return ACPAgent( + command="test-cmd", + args=["--flag"], + name="test-acp-agent", + init_request=init_request, + proxy_chain=proxy_chain, + ) + + +def _inject_mocks(agent: ACPAgent[None]) -> MagicMock: + """Inject mock _api, _client_handler, _connection, _sdk_session_id. + + Returns the mock API for further assertions. + """ + mock_api = MagicMock(name="ACPAgentAPI") + agent._api = mock_api + mock_handler = MagicMock(name="ACPClientHandler") + mock_handler.cleanup = AsyncMock() + agent._client_handler = mock_handler + agent._sdk_session_id = "acp-session-123" + mock_connection = MagicMock(name="ClientSideConnection") + mock_connection.close = AsyncMock() + agent._connection = mock_connection + agent._init_response = MagicMock(name="InitResponse") + return mock_api + + +# --------------------------------------------------------------------------- +# Hook recording helpers +# --------------------------------------------------------------------------- + + +_hook_calls: list[str] = [] + + +def _reset_hook_calls() -> None: + _hook_calls.clear() + + +def _make_recording_hook(event: str) -> CallableHook: + def _fn(**kwargs: Any) -> HookResult: + _hook_calls.append(event) + return {"decision": "allow"} + + return CallableHook(event=event, fn=_fn) # type: ignore[arg-type] + + +def _make_turn_with_client( + client: MockACPClient, + *, + hooks: AgentHooks | None = None, + prompts: list[str] | None = None, + session_id: str = "conductor-test-session", +) -> ACPTurn: + """Create an ACPTurn directly with a mock client.""" + return ACPTurn( + acp_client=client, # type: ignore[arg-type] + prompts=prompts or ["test prompt"], + run_ctx=_make_run_ctx(session_id), + message_history=[], + session_id=session_id, + agent_name="test-acp-agent", + hooks=hooks, + ) + + +# --------------------------------------------------------------------------- +# Test 1: __aenter__ creates Conductor +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +async def test_acp_agent_aenter_creates_conductor() -> None: + """Given __aenter__, _setup_conductor() is called. + + We patch _setup_conductor to avoid real subprocess, and verify + it was called and _conductor is set afterward. + """ + agent = _make_acp_agent(proxy_chain=[MagicMock()]) + _inject_mocks(agent) + + # Patch _start_process and _initialize + _create_session to avoid subprocess + with ( + patch.object( + ACPAgent, + "_start_process", + new_callable=AsyncMock, + ) as mock_start, + patch.object( + ACPAgent, + "_initialize", + new_callable=AsyncMock, + ), + patch.object( + ACPAgent, + "_create_session", + new_callable=AsyncMock, + ), + patch.object( + ACPAgent, + "_setup_conductor", + new_callable=AsyncMock, + ) as mock_setup_conductor, + patch("agentpool.agents.acp_agent.acp_agent.run_with_process_monitor"), + patch("anyio.sleep", new_callable=AsyncMock), + ): + mock_start.return_value = MagicMock() + + # _setup_conductor mock needs to set self._conductor with process + mock_cond = MagicMock() + mock_cond.process = MagicMock() + mock_cond.connection = MagicMock() + + async def _mock_setup() -> None: + agent._conductor = mock_cond + + mock_setup_conductor.side_effect = _mock_setup + await agent.__aenter__() + + assert mock_setup_conductor.call_count == 1 + await agent.__aexit__(None, None, None) + + +# --------------------------------------------------------------------------- +# Test 2: Zero-proxy backward compat (proxy_chain=None) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +async def test_acp_agent_zero_proxy_backward_compat() -> None: + """Given proxy_chain=None, ACPAgent still creates turns and runs. + + The agent should function identically to pre-Conductor behavior. + """ + agent = _make_acp_agent(proxy_chain=None) + _inject_mocks(agent) + + # Verify create_turn works without proxy_chain + turn = agent.create_turn( + prompts=["hello"], + run_ctx=_make_run_ctx(), + message_history=[], + ) + assert isinstance(turn, ACPTurn) + assert turn._prompts == ["hello"] + + # Verify _setup_conductor passes empty proxy_chain to Conductor + with ( + patch("acp.conductor.Conductor") as mock_conductor_cls, + ): + mock_conductor = AsyncMock() + mock_conductor.__aenter__ = AsyncMock(return_value=mock_conductor) + mock_conductor.__aexit__ = AsyncMock(return_value=None) + mock_conductor_cls.return_value = mock_conductor + + await agent._setup_conductor() + + # Conductor should be called with proxy_chain=[] (empty list) + call_kwargs = mock_conductor_cls.call_args + assert call_kwargs.kwargs["proxy_chain"] == [] + assert agent._conductor is mock_conductor + + +# --------------------------------------------------------------------------- +# Test 3: proxy_chain config is passed to Conductor +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +async def test_acp_agent_proxy_chain_config() -> None: + """Given a proxy_chain list, ACPAgent passes it to Conductor.""" + fake_proxy_1 = MagicMock(name="proxy1") + fake_proxy_2 = MagicMock(name="proxy2") + proxy_chain = [fake_proxy_1, fake_proxy_2] + + agent = _make_acp_agent(proxy_chain=proxy_chain) + _inject_mocks(agent) + + with patch("acp.conductor.Conductor") as mock_conductor_cls: + mock_conductor = AsyncMock() + mock_conductor.__aenter__ = AsyncMock(return_value=mock_conductor) + mock_conductor.__aexit__ = AsyncMock(return_value=None) + mock_conductor_cls.return_value = mock_conductor + + await agent._setup_conductor() + + call_kwargs = mock_conductor_cls.call_args + assert call_kwargs.kwargs["proxy_chain"] == proxy_chain + assert len(call_kwargs.kwargs["proxy_chain"]) == 2 + assert agent._conductor is mock_conductor + + +# --------------------------------------------------------------------------- +# Test 4: Multi-turn hooks fire per turn (not double-fired) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +async def test_multi_turn_hooks_fire_per_turn() -> None: + """Given 3 sequential turns, hooks fire exactly 3 times (once per turn). + + The double-fire guard (hooks_fired set) prevents duplicate firing + within a single turn. Across turns, each turn gets fresh hooks_fired. + """ + _reset_hook_calls() + hooks = AgentHooks( + pre_turn=[_make_recording_hook("pre_turn")], + post_turn=[_make_recording_hook("post_turn")], + ) + + for turn_idx in range(3): + client = MockACPClient( + updates=[_text_update(f"turn-{turn_idx}"), TurnCompleteUpdate()], + messages=[_text_update(f"turn-{turn_idx}")], + ) + turn = _make_turn_with_client(client, hooks=hooks) + + events = [event async for event in turn.execute()] + + # Each turn should produce StreamCompleteEvent + assert any(isinstance(e, StreamCompleteEvent) for e in events) + + # pre_turn and post_turn should each fire exactly 3 times + pre_turn_count = _hook_calls.count("pre_turn") + post_turn_count = _hook_calls.count("post_turn") + assert pre_turn_count == 3, f"Expected 3 pre_turn calls, got {pre_turn_count}" + assert post_turn_count == 3, f"Expected 3 post_turn calls, got {post_turn_count}" + + +# --------------------------------------------------------------------------- +# Test 5: Multi-turn events stream correctly across turns +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +async def test_multi_turn_events_stream_correctly() -> None: + """Given 3 turns, events stream without loss across turns. + + Each turn yields PartDeltaEvent + StreamCompleteEvent. No events + from one turn should bleed into another. + """ + _reset_hook_calls() + + for turn_idx in range(3): + updates = [ + _text_update(f"chunk-{turn_idx}-a"), + _text_update(f"chunk-{turn_idx}-b"), + TurnCompleteUpdate(), + ] + messages = [_text_update(f"result-{turn_idx}")] + client = MockACPClient(updates=updates, messages=messages) + turn = _make_turn_with_client(client) + + events = [event async for event in turn.execute()] + + # Should have 2 PartDeltaEvents + 1 StreamCompleteEvent + delta_events = [e for e in events if isinstance(e, PartDeltaEvent)] + complete_events = [e for e in events if isinstance(e, StreamCompleteEvent)] + + assert len(delta_events) == 2, ( + f"Turn {turn_idx}: expected 2 deltas, got {len(delta_events)}" + ) + assert len(complete_events) == 1, ( + f"Turn {turn_idx}: expected 1 complete, got {len(complete_events)}" + ) + + # Verify content matches this turn's expected text + expected_text = f"result-{turn_idx}" + assert complete_events[0].message.content == expected_text, ( + f"Turn {turn_idx}: expected content '{expected_text}', " + f"got '{complete_events[0].message.content}'" + ) + + # Verify final_message is populated per turn + assert turn._final_message is not None + assert turn._final_message.content == expected_text diff --git a/tests/agents/acp_agent/test_create_turn.py b/tests/agents/acp_agent/test_create_turn.py index 4ccbc9de0..a646ecb23 100644 --- a/tests/agents/acp_agent/test_create_turn.py +++ b/tests/agents/acp_agent/test_create_turn.py @@ -14,10 +14,11 @@ @pytest.mark.unit def test_acp_agent_create_turn_returns_acp_turn() -> None: - """Given an ACPAgent with mocked API, create_turn() returns an ACPTurn.""" + """Given an ACPAgent with mocked API and client_handler, create_turn() returns an ACPTurn.""" init_request = MagicMock(spec=InitializeRequest) agent = ACPAgent(command="test-cmd", init_request=init_request) agent._api = MagicMock() + agent._client_handler = MagicMock() agent._sdk_session_id = "test-session-id" run_ctx = AgentRunContext(session_id="test-run-ctx") diff --git a/tests/agents/acp_agent/test_turn.py b/tests/agents/acp_agent/test_turn.py index 28f4dac9e..d95b09384 100644 --- a/tests/agents/acp_agent/test_turn.py +++ b/tests/agents/acp_agent/test_turn.py @@ -4,7 +4,6 @@ import asyncio from typing import TYPE_CHECKING, Any -from unittest.mock import MagicMock import pytest @@ -36,26 +35,31 @@ def __init__( prompt_error: Exception | None = None, stream_error: Exception | None = None, get_messages_error: Exception | None = None, + stop_reason: str | None = "end_turn", ) -> None: self._updates = updates or [] self._messages = messages or [] self._prompt_error = prompt_error self._stream_error = stream_error self._get_messages_error = get_messages_error + self._stop_reason = stop_reason self.prompt_calls: list[tuple[str, list[Any]]] = [] - async def prompt(self, session_id: str, content: list[Any]) -> Any: + async def prompt(self, session_id: str, content: list[Any]) -> None: self.prompt_calls.append((session_id, content)) if self._prompt_error: raise self._prompt_error - return MagicMock(name="PromptResponse") - async def stream_events(self, response: Any) -> AsyncIterator[Any]: + async def stream_events(self) -> AsyncIterator[Any]: for update in self._updates: yield update if self._stream_error: raise self._stream_error + @property + def stop_reason(self) -> str | None: + return self._stop_reason + async def get_messages(self, session_id: str) -> list[Any]: if self._get_messages_error: raise self._get_messages_error diff --git a/tests/agents/acp_agent/test_turn_integration.py b/tests/agents/acp_agent/test_turn_integration.py index 4c26ff6ee..8b02088b9 100644 --- a/tests/agents/acp_agent/test_turn_integration.py +++ b/tests/agents/acp_agent/test_turn_integration.py @@ -49,17 +49,21 @@ def __init__( self._messages = messages or [] self._prompt_error = prompt_error self.prompt_calls: list[tuple[str, list[Any]]] = [] + self._stop_reason: str | None = "end_turn" - async def prompt(self, session_id: str, content: list[Any]) -> Any: + async def prompt(self, session_id: str, content: list[Any]) -> None: self.prompt_calls.append((session_id, content)) if self._prompt_error: raise self._prompt_error - return MagicMock(name="PromptResponse") - async def stream_events(self, response: Any) -> AsyncIterator[Any]: + async def stream_events(self) -> AsyncIterator[Any]: for update in self._updates: yield update + @property + def stop_reason(self) -> str | None: + return self._stop_reason + async def get_messages(self, session_id: str) -> list[Any]: return list(self._messages) @@ -215,9 +219,9 @@ async def _consume() -> None: @pytest.mark.unit def test_acp_agent_create_turn_returns_acp_turn_with_correct_fields() -> None: - """Given an ACPAgent with mocked _api, create_turn() returns ACPTurn. + """Given an ACPAgent with mocked _api and _client_handler, create_turn() returns ACPTurn. - Verifies the returned ACPTurn has correct acp_client, prompts, run_ctx, + Verifies the returned ACPTurn has correct prompts, run_ctx, message_history, and session_id fields. """ init_request = MagicMock(spec=InitializeRequest) @@ -225,6 +229,8 @@ def test_acp_agent_create_turn_returns_acp_turn_with_correct_fields() -> None: mock_api = MagicMock(name="ACPAgentAPI") agent._api = mock_api + mock_handler = MagicMock(name="ACPClientHandler") + agent._client_handler = mock_handler agent._sdk_session_id = "acp-session-123" run_ctx = _make_run_ctx(session_id="run-ctx-session") @@ -240,7 +246,10 @@ def test_acp_agent_create_turn_returns_acp_turn_with_correct_fields() -> None: assert isinstance(turn, ACPTurn) # Verify fields are correctly wired - assert turn._acp_client is mock_api + # _acp_client is now an ACPClientAdapter wrapping mock_api and mock_handler + from agentpool.agents.acp_agent.adapter import ACPClientAdapter + + assert isinstance(turn._acp_client, ACPClientAdapter) assert turn._prompts == ["hello world"] assert turn._run_ctx is run_ctx # session_id should use _sdk_session_id when available diff --git a/tests/agents/test_create_turn.py b/tests/agents/test_create_turn.py index f02808fd0..467104269 100644 --- a/tests/agents/test_create_turn.py +++ b/tests/agents/test_create_turn.py @@ -62,18 +62,17 @@ def test_acp_turn_joins_all_prompts_not_just_last() -> None: assert "third prompt" in new_result -def test_acp_adapter_has_todo_comment() -> None: - """ACP agent adapter gap must be documented with TODO, not just NOTE. +def test_acp_adapter_uses_acp_client_adapter() -> None: + """ACPAgent.create_turn() must use ACPClientAdapter (not cast to ACPClientProtocol). - The TODO comment must describe the required infrastructure - (async futures / notification registry) to prevent runtime crashes. + The adapter bridges the blocking ACPAgentAPI to the non-blocking + ACPClientProtocol interface expected by ACPTurn. """ import agentpool.agents.acp_agent.acp_agent as acp_module source = inspect.getsource(acp_module.ACPAgent.create_turn) - assert "TODO" in source, "ACP adapter gap must be documented with TODO comment, not just NOTE" - assert "AttributeError" in source or "adapter" in source.lower(), ( - "TODO comment must describe the gap and required infrastructure" + assert "ACPClientAdapter" in source, ( + "create_turn() must use ACPClientAdapter to bridge ACPAgentAPI" ) diff --git a/tests/hooks/test_hook_smoke_matrix.py b/tests/hooks/test_hook_smoke_matrix.py index bd6e8fcc1..c5a392a55 100644 --- a/tests/hooks/test_hook_smoke_matrix.py +++ b/tests/hooks/test_hook_smoke_matrix.py @@ -177,16 +177,19 @@ class _FakeACPClient: def __init__(self, updates: list[Any], messages: list[Any]) -> None: self._updates = updates self._messages = messages + self._stop_reason: str | None = "end_turn" - async def prompt(self, session_id: str, content: list[Any]) -> Any: - from acp.schema import PromptResponse + async def prompt(self, session_id: str, content: list[Any]) -> None: + pass - return PromptResponse(stop_reason="end_turn") - - async def stream_events(self, response: Any) -> Any: + async def stream_events(self) -> Any: for update in self._updates: yield update + @property + def stop_reason(self) -> str | None: + return self._stop_reason + async def get_messages(self, session_id: str) -> list[Any]: return list(self._messages) diff --git a/tests/servers/acp_server/test_acp_session_process_prompt_turn_complete.py b/tests/servers/acp_server/test_acp_session_process_prompt_turn_complete.py deleted file mode 100644 index 94886d002..000000000 --- a/tests/servers/acp_server/test_acp_session_process_prompt_turn_complete.py +++ /dev/null @@ -1,196 +0,0 @@ -"""Tests for ACPSession.process_prompt() passing client_supports_turn_complete flag. - -Verifies that process_prompt derives the client_supports_turn_complete flag from -self.client_capabilities.turn_complete and passes it to ACPEventConverter. -""" - -from __future__ import annotations - -from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from acp.schema import TextContentBlock -from acp.schema.capabilities import ClientCapabilities -from agentpool import Agent, AgentPool -from agentpool_server.acp_server.event_converter import ACPEventConverter -from agentpool_server.acp_server.session import ACPSession - - -if TYPE_CHECKING: - from collections.abc import AsyncIterator - - -@pytest.fixture -def agent_pool() -> AgentPool: - """Create a real agent pool with a test agent.""" - - def simple_callback(message: str) -> str: - return f"Response: {message}" - - from agentpool.models.agents import NativeAgentConfig - from agentpool.models.manifest import AgentsManifest - - manifest = AgentsManifest(agents={"test_agent": NativeAgentConfig(model="test")}) - - pool = AgentPool(manifest) - Agent.from_callback(name="test_agent", callback=simple_callback, agent_pool=pool) - # pool.register() removed; agent created from callback/config above - return pool - - -@pytest.fixture -def mock_acp_agent() -> MagicMock: - """Create a mock ACP agent with tasks support.""" - mock = MagicMock() - mock.tasks.create_task = lambda coro, *, name=None: coro # type: ignore[assignment,method-assign] - return mock - - -async def _run_stream_empty(*args: Any, **kwargs: Any) -> AsyncIterator[Any]: - """Empty async generator for mocking agent.run_stream.""" - return - yield # Make this an async generator - - -class TestProcessPromptTurnCompleteFlag: - """RED FLAG: process_prompt must pass client_supports_turn_complete to ACPEventConverter.""" - - @pytest.mark.anyio - async def test_process_prompt_passes_turn_complete_true( - self, - agent_pool: AgentPool, - mock_acp_agent: MagicMock, - ) -> None: - """When client_capabilities.turn_complete=True, ACPEventConverter must be. - - created with client_supports_turn_complete=True. - - """ - agent = agent_pool.manifest.agents["test_agent"].get_agent(pool=agent_pool) - mock_client = AsyncMock() - - session = ACPSession( - session_id="test-session", - agent=agent, - cwd="/tmp", - client=mock_client, - acp_agent=mock_acp_agent, - client_capabilities=ClientCapabilities(turn_complete=True), - ) - - # Mock run_stream to yield nothing - agent.run_stream = _run_stream_empty # type: ignore[method-assign] - - # Mock with_session_providers as no-op async context manager - @asynccontextmanager - async def _noop_ctx(*args: Any, **kwargs: Any) -> AsyncIterator[None]: - yield - - agent.tools.with_session_providers = _noop_ctx # type: ignore[method-assign] - - captured_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] - original_init = ACPEventConverter.__init__ - - def _capture_init(self: ACPEventConverter, *args: Any, **kwargs: Any) -> None: - captured_calls.append((args, kwargs)) - original_init(self, *args, **kwargs) - - with patch.object(ACPEventConverter, "__init__", _capture_init): - await session.process_prompt([TextContentBlock(text="hello")]) - - assert len(captured_calls) == 1 - _args, kwargs = captured_calls[0] - assert kwargs.get("client_supports_turn_complete") is True - - @pytest.mark.anyio - async def test_process_prompt_passes_turn_complete_false( - self, - agent_pool: AgentPool, - mock_acp_agent: MagicMock, - ) -> None: - """When client_capabilities.turn_complete=False, ACPEventConverter must be. - - created with client_supports_turn_complete=False. - - """ - agent = agent_pool.manifest.agents["test_agent"].get_agent(pool=agent_pool) - mock_client = AsyncMock() - - session = ACPSession( - session_id="test-session", - agent=agent, - cwd="/tmp", - client=mock_client, - acp_agent=mock_acp_agent, - client_capabilities=ClientCapabilities(turn_complete=False), - ) - - agent.run_stream = _run_stream_empty # type: ignore[method-assign] - - @asynccontextmanager - async def _noop_ctx(*args: Any, **kwargs: Any) -> AsyncIterator[None]: - yield - - agent.tools.with_session_providers = _noop_ctx # type: ignore[method-assign] - - captured_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] - original_init = ACPEventConverter.__init__ - - def _capture_init(self: ACPEventConverter, *args: Any, **kwargs: Any) -> None: - captured_calls.append((args, kwargs)) - original_init(self, *args, **kwargs) - - with patch.object(ACPEventConverter, "__init__", _capture_init): - await session.process_prompt([TextContentBlock(text="hello")]) - - assert len(captured_calls) == 1 - _args, kwargs = captured_calls[0] - assert kwargs.get("client_supports_turn_complete") is False - - @pytest.mark.anyio - async def test_process_prompt_defaults_turn_complete_when_none( - self, - agent_pool: AgentPool, - mock_acp_agent: MagicMock, - ) -> None: - """When client_capabilities.turn_complete=None, ACPEventConverter must be. - - created with client_supports_turn_complete=False (default). - - """ - agent = agent_pool.manifest.agents["test_agent"].get_agent(pool=agent_pool) - mock_client = AsyncMock() - - session = ACPSession( - session_id="test-session", - agent=agent, - cwd="/tmp", - client=mock_client, - acp_agent=mock_acp_agent, - client_capabilities=ClientCapabilities(turn_complete=None), - ) - - agent.run_stream = _run_stream_empty # type: ignore[method-assign] - - @asynccontextmanager - async def _noop_ctx(*args: Any, **kwargs: Any) -> AsyncIterator[None]: - yield - - agent.tools.with_session_providers = _noop_ctx # type: ignore[method-assign] - - captured_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] - original_init = ACPEventConverter.__init__ - - def _capture_init(self: ACPEventConverter, *args: Any, **kwargs: Any) -> None: - captured_calls.append((args, kwargs)) - original_init(self, *args, **kwargs) - - with patch.object(ACPEventConverter, "__init__", _capture_init): - await session.process_prompt([TextContentBlock(text="hello")]) - - assert len(captured_calls) == 1 - _args, kwargs = captured_calls[0] - assert kwargs.get("client_supports_turn_complete") is False diff --git a/tests/servers/acp_server/test_passthrough_zero_conversion.py b/tests/servers/acp_server/test_passthrough_zero_conversion.py new file mode 100644 index 000000000..f5290374b --- /dev/null +++ b/tests/servers/acp_server/test_passthrough_zero_conversion.py @@ -0,0 +1,268 @@ +"""Tests for PassthroughEventConverter — zero-conversion during proxy chain passthrough. + +Tests verify that: +- PassthroughEventConverter.convert() yields nothing for all event types +- PassthroughEventConverter.cancel_pending_tools() is a no-op +- PassthroughEventConverter satisfies the EventConverterComponent protocol +- When PassthroughEventConverter is used, ACPEventConverter.convert is NOT called +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +from pydantic_ai import PartDeltaEvent, PartStartEvent, TextPart, TextPartDelta +import pytest + +from acp.schema import Usage +from agentpool.agents.events.events import ( + RunStartedEvent, + StreamCompleteEvent, + ToolCallStartEvent, +) +from agentpool_server.acp_server.event_converter import ( + ACPEventConverter, + EventConverterComponent, + PassthroughEventConverter, +) + + +pytestmark = [pytest.mark.unit] + + +# --------------------------------------------------------------------------- +# Helper: build a minimal mock ChatMessage for StreamCompleteEvent +# --------------------------------------------------------------------------- + + +def _make_mock_message() -> Any: + """Create a mock ChatMessage suitable for StreamCompleteEvent.""" + mock_msg = MagicMock() + mock_msg.usage.total_tokens = 100 + mock_msg.usage.input_tokens = 50 + mock_msg.usage.output_tokens = 50 + mock_msg.usage.details = {} + mock_msg.usage.cache_read_tokens = 0 + mock_msg.usage.cache_write_tokens = 0 + mock_msg.cost_info = None + return mock_msg + + +# --------------------------------------------------------------------------- +# Test 1: PassthroughEventConverter.convert() yields nothing +# --------------------------------------------------------------------------- + + +async def test_passthrough_converter_yields_nothing() -> None: + """PassthroughEventConverter.convert() yields nothing for all event types. + + The passthrough converter must be a true no-op — it should not produce + any ACP session updates regardless of the event type received. + """ + converter = PassthroughEventConverter() + + # Test with various event types that would normally produce notifications + events: list[Any] = [ + PartStartEvent(index=0, part=TextPart(content="Hello")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta=" world")), + RunStartedEvent(run_id="run-1", agent_name="test_agent"), + ToolCallStartEvent( + tool_call_id="tc-1", + tool_name="bash", + title="Running bash", + kind="execute", + locations=[], + raw_input={"command": "echo hi"}, + ), + StreamCompleteEvent(message=_make_mock_message()), + ] + + for event in events: + results = [update async for update in converter.convert(event)] + assert results == [], ( + f"PassthroughEventConverter should yield nothing for {type(event).__name__}, " + f"but yielded {len(results)} updates" + ) + + +# --------------------------------------------------------------------------- +# Test 2: PassthroughEventConverter.cancel_pending_tools() is a no-op +# --------------------------------------------------------------------------- + + +async def test_passthrough_converter_cancel_pending_tools_noop() -> None: + """PassthroughEventConverter.cancel_pending_tools() yields nothing. + + Since the passthrough converter does not track tool state, cancelling + pending tools must be a complete no-op — no ToolCallProgress yields. + """ + converter = PassthroughEventConverter() + + # Feed some tool events first (they should all be ignored) + tool_event = ToolCallStartEvent( + tool_call_id="tc-passthrough-1", + tool_name="bash", + title="Running bash", + kind="execute", + locations=[], + raw_input={"command": "echo test"}, + ) + async for _ in converter.convert(tool_event): + pass + + # Now cancel — should yield nothing + results = [update async for update in converter.cancel_pending_tools()] + + assert results == [], "cancel_pending_tools should yield nothing in passthrough mode" + + +# --------------------------------------------------------------------------- +# Test 3: PassthroughEventConverter satisfies EventConverterComponent protocol +# --------------------------------------------------------------------------- + + +def test_passthrough_converter_satisfies_event_converter_protocol() -> None: + """PassthroughEventConverter implements the EventConverterComponent protocol. + + Since EventConverterComponent is a @runtime_checkable Protocol, we can + use isinstance() to verify that PassthroughEventConverter satisfies the + interface. This ensures it can be used wherever an EventConverterComponent + is expected (e.g., in ACPProtocolHandler). + """ + converter = PassthroughEventConverter() + + assert isinstance(converter, EventConverterComponent), ( + "PassthroughEventConverter must satisfy the EventConverterComponent protocol" + ) + + # Verify it has all required attributes/methods from the protocol + assert hasattr(converter, "subagent_display_mode") + assert hasattr(converter, "raw_input_mode") + assert hasattr(converter, "subagent_meta") + assert hasattr(converter, "last_usage") + assert hasattr(converter, "reset") + assert hasattr(converter, "convert") + assert hasattr(converter, "cancel_pending_tools") + assert hasattr(converter, "build_subagent_completed") + + +# --------------------------------------------------------------------------- +# Test 3b: PassthroughEventConverter still tracks usage from StreamCompleteEvent +# --------------------------------------------------------------------------- + + +async def test_passthrough_converter_tracks_usage_on_stream_complete() -> None: + """PassthroughEventConverter extracts usage from StreamCompleteEvent. + + Even though convert() yields nothing, it should still update + ``last_usage`` when it sees a StreamCompleteEvent. This allows the + proxy chain to maintain basic token accounting during passthrough. + """ + converter = PassthroughEventConverter() + assert converter.last_usage is None + + stream_event = StreamCompleteEvent(message=_make_mock_message()) + async for _ in converter.convert(stream_event): + pass # Should yield nothing + + assert converter.last_usage is not None + assert isinstance(converter.last_usage, Usage) + assert converter.last_usage.total_tokens == 100 + assert converter.last_usage.input_tokens == 50 + assert converter.last_usage.output_tokens == 50 + + +# --------------------------------------------------------------------------- +# Test 4: Zero conversion during passthrough — ACPEventConverter.convert NOT called +# --------------------------------------------------------------------------- + + +async def test_zero_conversion_during_passthrough() -> None: + """When PassthroughEventConverter is used, ACPEventConverter.convert is NOT called. + + This is the core guarantee of zero-conversion passthrough: when a + PassthroughEventConverter is substituted for an ACPEventConverter, the + expensive event-to-ACP conversion logic must never run. + + We verify this by spying on ACPEventConverter.convert and ensuring + it is never called while the passthrough converter processes events. + """ + # Create a real ACPEventConverter and spy on its convert method + acp_converter = ACPEventConverter() + convert_call_count = 0 + + original_convert = acp_converter.convert + + async def _counting_convert(event: Any) -> Any: + nonlocal convert_call_count + convert_call_count += 1 + async for update in original_convert(event): + yield update + + acp_converter.convert = _counting_convert # type: ignore[method-assign] + + # Create a passthrough converter (this is what gets used during passthrough) + passthrough_converter = PassthroughEventConverter() + + # Process events through the passthrough converter only + events: list[Any] = [ + PartStartEvent(index=0, part=TextPart(content="Hello world")), + PartDeltaEvent(index=0, delta=TextPartDelta(content_delta=" more text")), + StreamCompleteEvent(message=_make_mock_message()), + ] + + for event in events: + async for _ in passthrough_converter.convert(event): + pass # Passthrough yields nothing + + # The ACPEventConverter.convert should NEVER have been called + assert convert_call_count == 0, ( + "ACPEventConverter.convert must not be called during passthrough — " + f"it was called {convert_call_count} times" + ) + + # Sanity check: the ACPEventConverter WOULD have produced output for these events + # (verify the spy didn't break anything by calling it directly) + text_event = PartStartEvent(index=0, part=TextPart(content="Hello")) + direct_results = [update async for update in acp_converter.convert(text_event)] + assert len(direct_results) > 0, ( + "ACPEventConverter should produce output when called directly " + "(verifies the spy didn't break it)" + ) + assert convert_call_count == 1 # Only from our direct call above + + +# --------------------------------------------------------------------------- +# Test 5: PassthroughEventConverter.reset() clears usage +# --------------------------------------------------------------------------- + + +def test_passthrough_converter_reset_clears_usage() -> None: + """PassthroughEventConverter.reset() clears last_usage.""" + converter = PassthroughEventConverter() + # Simulate usage being set + converter.last_usage = Usage( + total_tokens=42, + input_tokens=20, + output_tokens=22, + ) + assert converter.last_usage is not None + + converter.reset() + + assert converter.last_usage is None + + +# --------------------------------------------------------------------------- +# Test 6: PassthroughEventConverter.build_subagent_completed() is a no-op +# --------------------------------------------------------------------------- + + +async def test_passthrough_converter_build_subagent_completed_noop() -> None: + """PassthroughEventConverter.build_subagent_completed() yields nothing.""" + converter = PassthroughEventConverter() + + results = [update async for update in converter.build_subagent_completed("child-session-123")] + + assert results == [], "build_subagent_completed should yield nothing in passthrough mode" diff --git a/tests/servers/acp_server/test_skill_command_staged_content.py b/tests/servers/acp_server/test_skill_command_staged_content.py index 36145de8f..a917dfd20 100644 --- a/tests/servers/acp_server/test_skill_command_staged_content.py +++ b/tests/servers/acp_server/test_skill_command_staged_content.py @@ -152,9 +152,8 @@ async def test_skill_command_with_staged_content_triggers_agent_run( content_block = TextContentBlock(text="/test-skill") - # Track whether session_pool.run_stream was called + # Track whether agent.run_stream was called run_stream_called = False - session_pool = agent_pool_with_skill._session_pool # type: ignore[reportPrivateUsage] def tracked_run_stream(*args: Any, **kwargs: Any) -> Any: nonlocal run_stream_called @@ -166,13 +165,13 @@ async def _empty() -> Any: return _empty() - original_run_stream = session_pool.run_stream - session_pool.run_stream = tracked_run_stream # type: ignore[method-assign] + original_run_stream = agent.run_stream + agent.run_stream = tracked_run_stream # type: ignore[method-assign] try: await session.process_prompt([content_block]) finally: - session_pool.run_stream = original_run_stream # type: ignore[method-assign] + agent.run_stream = original_run_stream # type: ignore[method-assign] assert run_stream_called, ( "agent.run_stream should be called when skill command injects content into staged_content" @@ -237,6 +236,7 @@ def simple_callback(message: str) -> str: result = await session.process_prompt([content_block]) - assert result == "end_turn", ( - "process_prompt should return end_turn when skill has no instructions" + # process_prompt returns None (no agent run needed for empty skill) + assert result is None, ( + "process_prompt should return None when skill has no instructions (no run)" ) diff --git a/tests/servers/acp_server/test_skill_content_delivery.py b/tests/servers/acp_server/test_skill_content_delivery.py index 045a6a9fd..b4d3df46d 100644 --- a/tests/servers/acp_server/test_skill_content_delivery.py +++ b/tests/servers/acp_server/test_skill_content_delivery.py @@ -105,11 +105,10 @@ async def test_skill_content_reaches_model_prompt(agent_pool_with_skill: AgentPo content_block = TextContentBlock(text="/test-skill some arguments") - # Capture what session_pool.run_stream receives - session_pool = agent_pool_with_skill._session_pool # type: ignore[reportPrivateUsage] + # Capture what agent.run_stream receives captured_args: tuple[Any, ...] = () captured_kwargs: dict[str, Any] = {} - original_run_stream = session_pool.run_stream + original_run_stream = agent.run_stream def mock_run_stream(*args: Any, **kwargs: Any) -> Any: nonlocal captured_args, captured_kwargs @@ -122,26 +121,25 @@ async def _empty() -> Any: return _empty() - session_pool.run_stream = mock_run_stream # type: ignore[method-assign] + agent.run_stream = mock_run_stream # type: ignore[method-assign] try: await session.process_prompt([content_block]) finally: - session_pool.run_stream = original_run_stream # type: ignore[method-assign] + agent.run_stream = original_run_stream # type: ignore[method-assign] # ASSERTIONS - assert captured_args, "session_pool.run_stream should have been called" - # The first arg is session_id - assert captured_args[0] == "test-session" + assert captured_args, "agent.run_stream should have been called" + # The first arg is the prompt text (which includes skill command) + assert "/test-skill" in captured_args[0] # Skill content may be passed via staged_content rather than as positional args async def test_skill_content_format_matches_opencode_pattern(agent_pool_with_skill: AgentPool): - """Verify skill content reaches session_pool.run_stream. + """Verify skill content reaches agent.run_stream. - process_prompt() now routes through session_pool.run_stream() instead of - calling agent._stream_events() directly. We verify that run_stream is called - with the skill instructions in the content. + process_prompt() routes through agent.run_stream(). We verify that + run_stream is called with the skill instructions in the content. """ agent = agent_pool_with_skill.manifest.agents["test_agent"].get_agent( pool=agent_pool_with_skill @@ -167,9 +165,8 @@ async def test_skill_content_format_matches_opencode_pattern(agent_pool_with_ski content_block = TextContentBlock(text="/test-skill some arguments") - session_pool = agent_pool_with_skill._session_pool # type: ignore[reportPrivateUsage] captured_args: tuple[Any, ...] = () - original_run_stream = session_pool.run_stream + original_run_stream = agent.run_stream def mock_run_stream(*args: Any, **kwargs: Any) -> Any: nonlocal captured_args @@ -181,12 +178,12 @@ async def _empty() -> Any: return _empty() - session_pool.run_stream = mock_run_stream # type: ignore[method-assign] + agent.run_stream = mock_run_stream # type: ignore[method-assign] try: await session.process_prompt([content_block]) finally: - session_pool.run_stream = original_run_stream # type: ignore[method-assign] + agent.run_stream = original_run_stream # type: ignore[method-assign] assert captured_args # run_stream was called — skill content is delivered via staged_content diff --git a/tests/servers/acp_server/test_terminal_agent.py b/tests/servers/acp_server/test_terminal_agent.py new file mode 100644 index 000000000..e1678b010 --- /dev/null +++ b/tests/servers/acp_server/test_terminal_agent.py @@ -0,0 +1,289 @@ +"""Integration tests for AgentPoolACPAgent as a terminal agent in proxy chains. + +Tests verify that AgentPoolACPAgent: +- Responds to the standard ``initialize`` method (terminal agent behavior) +- Does NOT handle ``proxy/initialize`` (that's a proxy-only method) +- Routes ``prompt()`` through ``ACPProtocolHandler.handle_prompt()`` +- Works as the terminal agent in a Conductor chain (mocked subprocess) +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from acp.proxy.constants import PROXY_INITIALIZE +from acp.schema import ( + ClientCapabilities, + Implementation, + InitializeRequest, + InitializeResponse, + PromptResponse, + TextContentBlock, +) + + +if TYPE_CHECKING: + from agentpool_server.acp_server.acp_agent import AgentPoolACPAgent + + +pytestmark = [pytest.mark.unit] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_init_request() -> InitializeRequest: + """Create a minimal InitializeRequest for testing.""" + return InitializeRequest( + protocol_version=1, + client_capabilities=ClientCapabilities(), + client_info=Implementation(name="test-client", version="0.1.0"), + ) + + +def _make_prompt_request(session_id: str, text: str) -> Any: + """Create a minimal PromptRequest with a text content block.""" + from acp.schema import PromptRequest + + return PromptRequest( + session_id=session_id, + prompt=[TextContentBlock(type="text", text=text)], + ) + + +# --------------------------------------------------------------------------- +# Test 1: AgentPoolACPAgent handles ``initialize`` method +# --------------------------------------------------------------------------- + + +async def test_terminal_agent_responds_to_initialize( + mock_acp_agent: AgentPoolACPAgent, +) -> None: + """AgentPoolACPAgent handles the standard ``initialize`` method. + + The terminal agent must respond to ``initialize`` with an + ``InitializeResponse`` containing the negotiated protocol version + and agent capabilities. This is the standard ACP handshake that + every terminal agent must support. + """ + request = _make_init_request() + + response = await mock_acp_agent.initialize(request) + + assert isinstance(response, InitializeResponse) + assert response.protocol_version == 1 + # AgentPoolACPAgent identifies itself as "agentpool" via agent_info + assert response.agent_info is not None + assert response.agent_info.name == "agentpool" + assert response.agent_info.title == "AgentPool" + # Capabilities should be advertised + assert response.agent_capabilities is not None + assert response.agent_capabilities.load_session is True + # Session capabilities (list, resume, close, fork) are nested + assert response.agent_capabilities.session_capabilities is not None + assert response.agent_capabilities.session_capabilities.list is not None + assert response.agent_capabilities.session_capabilities.resume is not None + # After initialize, the agent should be marked as initialized + assert mock_acp_agent._initialized is True + + +# --------------------------------------------------------------------------- +# Test 2: AgentPoolACPAgent does NOT handle ``proxy/initialize`` +# --------------------------------------------------------------------------- + + +async def test_terminal_agent_does_not_handle_proxy_initialize( + mock_acp_agent: AgentPoolACPAgent, +) -> None: + """AgentPoolACPAgent does NOT handle ``proxy/initialize``. + + The ``proxy/initialize`` method (PROXY_INITIALIZE) is a proxy-chain + extension method. Terminal agents are standard ACP agents and should + not respond to it. The ``ext_method`` handler should return an empty + dict for unknown extension methods, not a proxy initialization result. + """ + # ext_method is the handler for extension methods like proxy/initialize + result = await mock_acp_agent.ext_method( + PROXY_INITIALIZE, + {"interceptedMethods": ["session/prompt"]}, + ) + + # Terminal agent's ext_method returns {} for unknown methods + # (proxy/initialize is NOT a recognized extension method for terminal agents) + assert result == {} + + +# --------------------------------------------------------------------------- +# Test 3: prompt() delegates to ACPProtocolHandler.handle_prompt() +# --------------------------------------------------------------------------- + + +async def test_terminal_agent_prompt_routes_through_handler( + mock_acp_agent: AgentPoolACPAgent, +) -> None: + """prompt() delegates to ACPProtocolHandler.handle_prompt(). + + When ``_protocol_handler`` is set (SessionPool mode), ``prompt()`` + must delegate to ``handle_prompt()`` and return its result. This + ensures the consolidated prompt handling path is used. + """ + # Create a mock protocol handler + expected_response = PromptResponse(stop_reason="end_turn") + mock_handler = MagicMock() + mock_handler.handle_prompt = AsyncMock(return_value=expected_response) + + # Inject the mock handler + mock_acp_agent._protocol_handler = mock_handler + mock_acp_agent._initialized = True + + prompt_request = _make_prompt_request("test-session-123", "Hello, agent!") + + response = await mock_acp_agent.prompt(prompt_request) + + # Verify handle_prompt was called with the session_id and prompt + mock_handler.handle_prompt.assert_called_once_with( + "test-session-123", + prompt_request.prompt, + ) + assert response is expected_response + assert response.stop_reason == "end_turn" + + +# --------------------------------------------------------------------------- +# Test 3b: prompt() raises when no protocol handler is configured +# --------------------------------------------------------------------------- + + +async def test_terminal_agent_prompt_raises_without_handler( + mock_acp_agent: AgentPoolACPAgent, +) -> None: + """prompt() raises RuntimeError when no protocol handler is configured. + + After T22, the legacy ``process_prompt()`` path was removed. If + ``_protocol_handler`` is None, ``prompt()`` must raise rather than + silently fail. + """ + # Ensure no protocol handler is set + mock_acp_agent._protocol_handler = None + mock_acp_agent._initialized = True + + prompt_request = _make_prompt_request("test-session-456", "Hello!") + + with pytest.raises(RuntimeError, match="No protocol handler configured"): + await mock_acp_agent.prompt(prompt_request) + + +# --------------------------------------------------------------------------- +# Test 4: AgentPoolACPAgent works as terminal agent in a Conductor chain +# --------------------------------------------------------------------------- + + +async def test_terminal_agent_in_conductor_chain( + mock_acp_agent: AgentPoolACPAgent, +) -> None: + """AgentPoolACPAgent works as terminal agent in a Conductor chain. + + Simulates a Conductor with a single proxy that intercepts + ``session/prompt``. The terminal agent (AgentPoolACPAgent) receives + the forwarded prompt through its ``prompt()`` method, which delegates + to the protocol handler. + + The subprocess is mocked — no real process is spawned. + """ + from acp.conductor import Conductor + + # --- Set up the terminal agent (AgentPoolACPAgent) --- + expected_response = PromptResponse(stop_reason="end_turn") + mock_handler = MagicMock() + mock_handler.handle_prompt = AsyncMock(return_value=expected_response) + mock_acp_agent._protocol_handler = mock_handler + mock_acp_agent._initialized = True + + # --- Set up a fake proxy that intercepts session/prompt --- + class _FakeProxy: + """Fake proxy that intercepts session/prompt and forwards params.""" + + def __init__(self) -> None: + self.successor_calls: list[tuple[str, dict[str, Any], dict[str, Any]]] = [] + + def proxy_initialize(self) -> list[str]: + return ["session/prompt"] + + async def proxy_successor( + self, + method: str, + params: dict[str, Any], + meta: dict[str, Any], + ) -> dict[str, Any]: + self.successor_calls.append((method, params, meta)) + # Modify the prompt text to prove the proxy ran + params["prompt"] = [{"type": "text", "text": "proxied: hello"}] + return params + + fake_proxy = _FakeProxy() + + # --- Create a Conductor with the fake proxy --- + conductor = Conductor( + name="test_terminal", + command="echo", + args=["dummy"], + proxy_chain=[fake_proxy], + ) + + # --- Set up Conductor internal state without spawning a subprocess --- + # Mock the connection so send_request routes to our terminal agent + async def _fake_send_request(method: str, params: dict[str, Any]) -> Any: + """Simulate the terminal agent receiving a JSON-RPC request.""" + if method == "initialize": + return {"result": {"protocolVersion": 1, "name": "agentpool"}} + if method == "session/prompt": + # Build a PromptRequest from the JSON-RPC params and call the agent + session_id = params.get("sessionId", "conductor-session") + prompt_blocks = [TextContentBlock.model_validate(b) for b in params.get("prompt", [])] + from acp.schema import PromptRequest + + request = PromptRequest(session_id=session_id, prompt=prompt_blocks) + response = await mock_acp_agent.prompt(request) + return {"result": {"stopReason": response.stop_reason}} + return {"result": {}} + + mock_connection = MagicMock() + mock_connection.send_request = _fake_send_request + conductor._connection = mock_connection + conductor._conductor_initialized = True + + # Populate intercepted_methods as _initialize_chain would + conductor._intercepted_methods = [fake_proxy.proxy_initialize()] + conductor._chain_initialized = True + + # --- Route a session/prompt through the Conductor --- + route_params: dict[str, Any] = { + "sessionId": "conductor-session", + "prompt": [{"type": "text", "text": "hello"}], + } + route_meta: dict[str, Any] = {"direction": "forward"} + + result = await conductor._route_message("session/prompt", route_params, route_meta) + + # --- Assertions --- + # 1. The proxy should have intercepted and modified the prompt (forward + reverse = 2) + assert len(fake_proxy.successor_calls) == 2 + intercepted_method, intercepted_params, _ = fake_proxy.successor_calls[0] + assert intercepted_method == "session/prompt" + assert intercepted_params["prompt"][0]["text"] == "proxied: hello" + + # 2. The terminal agent's handler should have been called with the proxied prompt + mock_handler.handle_prompt.assert_called_once() + call_args = mock_handler.handle_prompt.call_args + forwarded_prompt = call_args[0][1] # Second positional arg = prompt blocks + assert len(forwarded_prompt) == 1 + assert forwarded_prompt[0].text == "proxied: hello" + + # 3. The Conductor should return the terminal agent's response + assert "result" in result + assert result["result"]["stopReason"] == "end_turn"