Skip to content

feat(acp): ACP proxy chain refactor — Conductor, HookProxy, zero-conversion passthrough - #127

Draft
Leoyzen wants to merge 50 commits into
develop/agenticfrom
feature/acp-proxy-chain-refactor
Draft

feat(acp): ACP proxy chain refactor — Conductor, HookProxy, zero-conversion passthrough#127
Leoyzen wants to merge 50 commits into
develop/agenticfrom
feature/acp-proxy-chain-refactor

Conversation

@Leoyzen

@Leoyzen Leoyzen commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Refactors the ACP agent layer from a monolithic design into a proxy chain architecture with a Conductor pattern, enabling zero-conversion passthrough for nested ACP agents.

Closes #123

Problem

The current ACPAgent implementation conflates subprocess management, ACP client communication, and event conversion into a single monolithic class with three critical issues:

  1. ACPTurn is dead code — the designed Turn abstraction was non-functional due to a missing adapter
  2. 50ms polling loop — streaming used polling instead of async push
  3. ~1600 lines of bidirectional event conversion — nesting ACP server + client caused ACP→native→ACP conversion that should be zero-copy passthrough

Solution

Implements the proxy chain RFD conductor pattern across 7 phases (25 tasks):

Phase 1 (T0-T6): ACPClientAdapter + ACPTurn revival

  • ACPClientAdapter bridges blocking ACPAgentAPI.prompt() to non-blocking ACPClientProtocol (fire-and-forget + async queue)
  • ACPClientHandler.session_update() bifurcated: state updates in-place, stream data to async queue
  • ACPSessionState renamed to ACPState, deque removed
  • _stream_events() delegates to ACPTurn.execute() — no more polling

Phase 2 (T7-T12): Conductor + Proxy protocol

  • src/acp/proxy/ package: Proxy protocol, ProxySideConnection, wire constants
  • Conductor class (src/acp/conductor.py): MessageNode inheritance, AsyncExitStack lifecycle, chain initialization, message routing with passthrough optimization, error propagation

Phase 3 (T13-T16): ACPAgent rewrite

  • Output type: strChatMessage[str]
  • __init__ accepts proxy_chain and agent_hooks (auto-inserts HookProxy)
  • Config: ProxyChainConfig model, get_tool_factories() (ToolsetFactory migration)

Phase 4 (T17-T21): Built-in proxy implementations

  • HookProxy: wraps AgentHooks, maps all 4 hook types (pre_turn, post_turn, pre_tool_use, post_tool_use)
  • ContextInjectionProxy: prepends AGENTS.md + skill instructions
  • ToolProviderProxy: experimental MCP-over-ACP stub
  • HookProxy/HookAwareTurn coexistence: _hooks=None guard, set_hooks_enabled(False) on handler

Phase 5 (T22-T23): Server-side adaptation

  • Legacy process_prompt() dual path removed — consolidated to ACPProtocolHandler.handle_prompt()
  • ACPEventConverter split: 8 stateless functions + PassthroughEventConverter (zero-conversion)
  • EventConverterComponent protocol for proxy-wrappable converters

Phase 6 (T24): Cleanup

  • use_conductor feature flag removed (Conductor is only path)
  • Dead code deleted, unused imports cleaned

Test plan

  • 374 tests pass (tests/agents/acp_agent/, tests/acp/, tests/servers/acp_server/)
  • uv run ruff check src/ — clean
  • uv run --no-group docs mypy src/acp/ — clean
  • No cast()/getattr/hasattr in new files
  • No out-of-scope items (no remote transport, no session fork, no hot-swap)

Key files

File Description
src/acp/conductor.py Conductor class (~850 lines)
src/acp/proxy/protocol.py Proxy protocol (async proxy_successor)
src/acp/proxy/impls/hook_proxy.py HookProxy — all 4 hook mappings
src/acp/proxy/impls/context_injection.py AGENTS.md + skill injection
src/acp/proxy/impls/tool_provider.py Experimental MCP-over-ACP
src/agentpool/agents/acp_agent/adapter.py ACPClientAdapter (non-blocking)
src/agentpool_server/acp_server/event_converter.py Split + PassthroughEventConverter

OpenSpec

Change: openspec/changes/acp-proxy-chain-refactor/ (proposal, design, 6 specs, tasks)

Leoyzen added 16 commits July 7, 2026 21:33
OpenSpec change proposing unified hook architecture that fires all 4 hook
types (pre_turn, post_turn, pre_tool_use, post_tool_use) in Turn.execute()
via HookAwareTurn mixin, replacing fragmented firing in _run_stream_once()
and RunHandle.start().

Key decisions:
- D1: ALL hooks fire in Turn.execute() (convergence point for standalone + SessionPool)
- D2: ACP tool hooks advisory (ToolCallStart/Complete), permission hooks blocking
- D3: Keep Hook types, retire AgentHooks.as_capability() adapter
- D4: HookAwareTurn mixin handles ALL 4 hook types
- D5: Rename pre_run/post_run → pre_turn/post_turn (deprecated aliases, remove v0.5.0)
- D6: Three-tier test strategy (core/smoke/integration)
- D7: Dead code cleanup (stripping hack, delegate methods, broken tests)

Known gap: ACP standalone mode uses inline _stream_events() that bypasses
ACPTurn.execute() — hooks retained in _run_stream_once() until ACPAgentAPI
adapter is built (future work, tasks.md section 11).

Implements #124 (sub-issue of #123 audit).
Verified by Oracle (2 rounds) + Momus (1 round).
… multi-turn

Fixes 4 issues identified by gemini-code-assist review:

1. duration_ms for post_turn: Add duration_ms: float = 0.0 to
   run_post_turn_hooks() signature (task 2.7, D5 affected names)

2. ACP pre_tool_use double-firing: Add tool-call-ID-scoped guard
   f'pre_tool_use:{tool_call_id}' to prevent double-firing between
   request_permission() (blocking) and ToolCallStart (advisory).
   New scenario in acp-server spec.

3. HookAwareTurn context access: Use class variable annotations
   (_run_ctx: AgentRunContext | None = None) instead of properties.
   Properties prevent subclass __init__ from setting via simple
   assignment. Mock turns inherit None default (guard skipped).

4. hooks_fired breaks multi-turn: Clear hooks_fired at start of each
   turn (RunHandle.start() loop for Path A, _run_stream_once() for
   Path B). Also fix guard direction: in Path B, old path fires
   FIRST and adds to hooks_fired; Turn.execute() checks and skips.
   The original design had the direction backwards.

Oracle-evaluated solutions for all 4 comments.
…kEvent, AgentHooks, NativeAgentHookManager, and HooksConfig

refs: openspec/changes/unify-hook-system
… and hooks_fired guard

- HooksConfig: AliasChoices for pre_run/post_run backward compat with DeprecationWarning
- AgentRunContext: hooks_fired set field for double-fire prevention
- RunHandle.start() and _run_stream_once(): clear hooks_fired at turn start
- HookAwareTurn mixin: abstract properties + 4 hook-firing methods with guards

refs: openspec/changes/unify-hook-system
…rd old firing path

- NativeTurn: inherits HookAwareTurn, fires pre_turn/post_turn in execute(), deny handling
- ACPTurn: inherits HookAwareTurn, fires pre_turn/post_turn in execute(), advisory tool hooks
- base_agent.py: old hook firing guarded with hooks_fired set to prevent double-firing
- create_turn() methods pass hooks= parameter to turn constructors

refs: openspec/changes/unify-hook-system
- 5 test files, 39 tests (4 skipped for ACP SessionPool)
- Unit tests for HookAwareTurn mixin in isolation
- Integration tests for NativeTurn + ACPTurn with hooks
- E2E regression tests for SessionPool hook firing
- 16-cell smoke test matrix (4 hook types × 4 modes)
- Fix: ACPTurn post_turn hooks now fire on pre_turn deny
  (moved deny check inside try/finally block)
… aliases

- as_capability() in AgentHooks and NativeAgentHookManager emits
  DeprecationWarning pointing to HookAwareTurn as replacement
- HooksConfig field docstrings updated with deprecation notices
  for pre_run/post_run YAML aliases
- (Todos 8-9)
- Moved _ToolInterceptCapability to standalone tool_intercept.py module
- Removed stripping hack from as_capability()
- Removed unused delegate methods (run_pre_turn_hooks, run_post_turn_hooks,
  run_pre_run_hooks, run_post_run_hooks deprecated aliases)
- Simplified as_capability() to return ToolInterceptCapability directly
- Kept: __init__, has_hooks, agent_hooks, run_pre_tool_hooks,
  run_post_tool_hooks (still needed by ToolInterceptCapability)
- (Todo 10)
…, and old hook firing path (Todo 12)

- Remove as_capability() from AgentHooks and NativeAgentHookManager
- Remove _wrap_* helpers from agent_hooks.py
- Remove run_pre_run_hooks()/run_post_run_hooks() deprecated aliases
- Remove pre_run/post_run AliasChoices and model_validator from HooksConfig
- Guard old hook firing in base_agent.py with AGENT_TYPE != 'native'
- Native agents now fire hooks exclusively via HookAwareTurn
- ACP standalone path preserved (still uses old hook firing)
- Delete test_hooks_capability.py (19 tests for removed as_capability())
- Update tests to use ToolInterceptCapability directly
Proxy chain architecture for ACP agents:
- Conductor + Proxy protocol (proxy/initialize, proxy/successor per RFD)
- ACPClientAdapter bridging ACPAgentAPI → ACPClientProtocol
- HookProxy (wire-level, blocking) coexists with HookAwareTurn (in-process, advisory)
  via Conductor passing _hooks=None to ACPTurn when HookProxy is active
- Built-in proxies: ContextInjectionProxy, ToolProviderProxy
- 6 phases, 67 tasks total

Aligned with unify-hook-system spec:
- Uses pre_turn/post_turn naming (renamed by unify-hook-system)
- Implements Future Work Section 11 (ACPClientAdapter + _stream_events refactor)
- HookProxy handles all 4 hook types at wire level

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the hook system by introducing the HookAwareTurn mixin to unify hook execution across native and ACP agents, renaming pre_run/post_run events to pre_turn/post_turn to match per-turn semantics, and slimming down NativeAgentHookManager. The review feedback highlights a critical regression where standalone native runs no longer execute hooks due to changes in _run_stream_once. Additionally, the reviewer recommends removing runtime generic subscripts from ChatMessage instantiations in ACPTurn for consistency, and replacing hardcoded 0.0 durations in post-tool and post-turn hooks with actual elapsed time calculations to enable performance monitoring.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread tests/hooks/test_hooks.py
Comment thread src/agentpool/agents/acp_agent/turn.py Outdated
Comment thread src/agentpool/agents/acp_agent/turn.py Outdated
Comment thread src/agentpool/agents/acp_agent/turn.py
Comment thread src/agentpool/orchestrator/turn.py Outdated
Leoyzen added 13 commits July 8, 2026 10:00
…isks

Critical fixes:
- D3: Redefine ACPClientProtocol — prompt() returns None, stream_events()
  takes no args, add stop_reason property (was contradictory fire-and-forget
  returning PromptResponse)
- D4/D6: Fix proxy chain initialization ordering to match RFD (client→terminal,
  not terminal→client); terminal detection by chain position, not response
- D4: HookProxy post_turn triggers on JSON-RPC response correlation, not
  'final AgentMessageChunk' (which has no 'final' flag in ACP)

Design decision fixes:
- D5: Define passthrough registration mechanism (intercepted_methods in
  proxy/initialize response)
- D6: Reword to 'conductor decides by position' per RFD

Missing specs added:
- ACPClientProtocol signature changes (prompt→None, stream_events→no args)
- ACPClientHandler state update bifurcation (state in-place, stream to queue)
- Proxy chain error propagation (JSON-RPC error, no silent skip)
- Queue backpressure (max_buffer_size=1000)
- stop_reason property access semantics
- Concurrent prompts rejection
- Proxy hot-swap (explicitly out of scope)

Missing risks added:
- ACPSessionState deletion scope (preserve model/mode/config state)
- Two unratified RFDs dependency for ToolProviderProxy
- ACPClientHandler state update routing
- Unbounded queue

Timeline: 2-3 weeks → 4-5 weeks realistic
- Fix stale 'pre_run' → 'pre_turn' in acp-proxy-impls/spec.md
- Mark concurrent prompt rejection as 'resolved' in design.md Open Questions
  (already a formal requirement in acp-client-adapter/spec.md)
…e ACPState (T1-T5)

- T1: Create ACPClientAdapter class with stub methods, redefine ACPClientProtocol
- T2: Implement adapter methods (prompt, stream_events, stop_reason, get_messages)
  with non-blocking background task, asyncio.wait-based stream consumption,
  error propagation, and concurrent prompt rejection
- T3: Bifurcate ACPClientHandler.session_update() — state updates processed
  in-place, stream data pushed to _stream_queue
- T5: Rename ACPSessionState to ACPState, delete deque/add_update/pop_update

Co-authored-by: Atlas orchestrator
…nd _interrupt (T4)

- Replace _stream_events body with thin delegation to create_turn().execute()
- Fix create_turn to use ACPClientAdapter instead of cast()
- Fix _interrupt to mark run_ctx.cancelled instead of cancelling prompt_task
- Add stop_reason integration in ACPTurn.execute() for finish_reason
- Remove poll_acp_events and 50ms sleep polling
- Remove unused imports (cast, anyio streams, manual message construction)
- Widen to_finish_reason parameter type to str | None
…ifurcation (T6)

- Create test_adapter.py with 8 unit tests covering all adapter methods
- Fix FakeACPClient/MockACPClient in all test files to match new ACPClientProtocol
  (prompt→None, stream_events→no args, stop_reason property, get_messages)
- Fix test_create_turn to set up mock _client_handler
- All 64 tests pass, 0 failures
…ection (T7)

- src/acp/proxy/constants.py: PROXY_INITIALIZE, PROXY_SUCCESSOR wire method names
- src/acp/proxy/protocol.py: Proxy typing.Protocol with proxy_initialize/proxy_successor
- src/acp/proxy/connection.py: ProxySideConnection wrapping Connection for chain dispatch
- src/acp/proxy/__init__.py: Package exports
…ownership (T8)

- Conductor inherits MessageNode, owns ACPClientHandler lifecycle
- Uses AsyncExitStack for structured subprocess management
- _step property returns valid pydantic-graph Step (stub for T9/T11)
- ConductorConfig dataclass for subprocess configuration
- async context manager for cleanup
…ction (T9)

- Add _initialize_chain() for full init sequence (proxies then terminal)
- Add _initialize_proxy() and _initialize_terminal() methods
- Terminal/proxy detection by chain position (config, not responses)
- Zero-proxy case connects directly to terminal
- Error handling: proxy crash during init aborts and cleans up
…opagation (T10)

- Add _route_message() for bidirectional proxy/successor forwarding
- Add _should_intercept() using intercepted_methods for passthrough optimization (D5)
- Add _forward_through_proxies() — only calls proxies that intercept the method
- Add _handle_proxy_error() — produces JSON-RPC error response, no silent skipping
- Add _route_to_terminal() — core forward routing with passthrough shortcut
…er (T11)

- Full _step property returning pydantic_graph.Step
- _execute_step() routes messages through proxy chain and returns ChatMessage[str]
- get_stats() returns aggregated message stats
- run_iter() async iterator for multiple prompts
- Context manager cleanup via AsyncExitStack (no orphaned subprocesses)
- tests/acp/test_conductor.py: 45 tests covering chain init (zero-proxy,
  N-proxy, crash cleanup), routing (forward, passthrough, intercept,
  error), error handling (generic, RequestError), context manager
  (enter/exit/failure), properties, and _step
- tests/acp/test_proxy_protocol.py: 22 tests covering Proxy protocol
  (runtime_checkable, isinstance, method returns), ProxySideConnection
  (dispatch, forwarding, close, context manager), and constants
- All 67 tests pass, ruff clean, mypy clean
…p-proxy-chain-refactor

# Conflicts:
#	src/agentpool/agents/acp_agent/turn.py
#	tests/agents/acp_agent/test_acp_turn_hooks.py
@Leoyzen
Leoyzen marked this pull request as ready for review July 8, 2026 08:14
@Leoyzen

Leoyzen commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@Leoyzen
Leoyzen requested a review from Million-mo July 8, 2026 08:15
@gemini-code-assist

Copy link
Copy Markdown

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@Leoyzen

Leoyzen commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist

Copy link
Copy Markdown

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@Leoyzen

Leoyzen commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the ACP agent architecture to use a proxy chain model managed by a new Conductor class, making ACPTurn the single execution path and introducing built-in proxies like HookProxy and ContextInjectionProxy. The review feedback highlights several critical issues, including duplicate subprocess spawning in ACPAgent that bypasses the proxy chain, ignored subprocess notifications due to a NoOpClient fallback, and bypassed reverse proxy routing that prevents post_turn hooks from executing. Additionally, bugs were identified in HookProxy and ContextInjectionProxy where context is injected into the wrong parameter, along with code quality improvements regarding swallowed asyncio.CancelledError exceptions and redundant dead code.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/agentpool/agents/acp_agent/acp_agent.py
Comment thread src/acp/conductor.py
Comment thread src/acp/conductor.py
Comment thread src/acp/proxy/impls/hook_proxy.py
Comment thread src/acp/proxy/impls/context_injection.py
Comment thread src/agentpool/agents/acp_agent/adapter.py Outdated
Comment thread src/acp/conductor.py Outdated
@Leoyzen

Leoyzen commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist

Copy link
Copy Markdown

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@Leoyzen

Leoyzen commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the ACP agent layer to support proxy chains by introducing a Conductor class to manage proxy lifecycles, a Proxy protocol, several built-in proxy implementations (such as HookProxy and ContextInjectionProxy), and an ACPClientAdapter to bridge ACPAgentAPI to ACPClientProtocol. Feedback on these changes identifies several critical and high-severity issues, including a proxy chain bypass and stream conflict in ACPAgent, a startup crash due to self._process being None in proxy chain mode, a validation bug in ProxyChainConfig that rejects all proxy types, a hardcoded key bug in HookProxy, lost metadata during message routing in Conductor, potential resource leaks on initialization failure, and an unused _TerminalConnectionAdapter class.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/agentpool/agents/acp_agent/acp_agent.py
Comment thread src/agentpool/agents/acp_agent/acp_agent.py
Comment thread src/agentpool/models/acp_agents/proxy_chain.py
Comment thread src/acp/proxy/impls/hook_proxy.py Outdated
Comment thread src/acp/conductor.py
Comment thread src/agentpool/agents/acp_agent/acp_agent.py
Comment thread src/agentpool/agents/acp_agent/acp_agent.py Outdated
@Leoyzen

Leoyzen commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the ACP proxy chain architecture, introducing a Conductor to manage the proxy chain and terminal agent subprocess, and an ACPClientAdapter to bridge the blocking ACPAgentAPI to the non-blocking ACPClientProtocol. It refactors ACPAgent to use these new components, enables zero-conversion passthrough for performance, and integrates existing hook systems via HookProxy. Several improvements were identified regarding error handling in cleanup routines, logging practices, and the simplification of the ACPClientAdapter sentinel logic and to_finish_reason conversion.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/agentpool/models/acp_agents/proxy_chain.py Outdated
Comment thread src/acp/proxy/impls/hook_proxy.py Outdated
self._prompt_response = response
return response

self._prompt_task = asyncio.create_task(_run_prompt())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since ACPClientAdapter is instantiated dynamically inside create_turn() and is extremely short-lived, storing the fire-and-forget background task only in self._prompt_task is not a safe strong reference. If the adapter instance is garbage collected before the background task completes, the task itself can be garbage collected mid-execution. To prevent this, store the task in a module-level set of active background tasks and discard it when done.

Suggested change
self._prompt_task = asyncio.create_task(_run_prompt())
self._prompt_task = asyncio.create_task(_run_prompt())
_background_tasks.add(self._prompt_task)
self._prompt_task.add_done_callback(_background_tasks.discard)
References
  1. When creating fire-and-forget background tasks with asyncio.create_task, always keep a strong reference to the task (e.g., by storing it in a set of active background tasks) and discard it when done (using a done callback) to prevent the task from being garbage collected mid-execution.

Comment on lines +13 to +14
import asyncio
from typing import TYPE_CHECKING

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Define a module-level set to maintain strong references to active background tasks, preventing them from being garbage collected mid-execution.

Suggested change
import asyncio
from typing import TYPE_CHECKING
import asyncio
from typing import TYPE_CHECKING, Any
_background_tasks: set[asyncio.Task[Any]] = set()
References
  1. When creating fire-and-forget background tasks with asyncio.create_task, always keep a strong reference to the task (e.g., by storing it in a set of active background tasks) and discard it when done (using a done callback) to prevent the task from being garbage collected mid-execution.

Comment on lines +90 to +98
async def _run_prompt() -> PromptResponse:
"""Execute api.prompt() and store result or error."""
try:
response = await self._api.prompt(session_id, content)
except Exception as exc:
self._prompt_error = exc
raise
self._prompt_response = response
return response

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update _run_prompt to put None into the queue upon completion (in a finally block) to support the simplified sentinel-based streaming in stream_events.

Suggested change
async def _run_prompt() -> PromptResponse:
"""Execute api.prompt() and store result or error."""
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 _run_prompt() -> PromptResponse:
"""Execute api.prompt() and store result or error."""
try:
response = await self._api.prompt(session_id, content)
self._prompt_response = response
return response
except Exception as exc:
self._prompt_error = exc
raise
finally:
await self._queue.put(None)

Comment thread src/acp/conductor.py
Comment on lines +327 to +329
if self._exit_stack is not None:
await self._exit_stack.aclose()
self._exit_stack = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Wrap the exit stack cleanup in a try-except block to log unexpected exceptions instead of raising them. This prevents cleanup exceptions from masking other active exceptions raised during main processing, adhering to the repository's general rules.

Suggested change
if self._exit_stack is not None:
await self._exit_stack.aclose()
self._exit_stack = None
if self._exit_stack is not None:
try:
await self._exit_stack.aclose()
except Exception:
logger.exception("Failed to close exit stack during cleanup")
self._exit_stack = None
References
  1. Wrap cleanup operations (such as unsubscribing, closing connections, or releasing resources) inside finally blocks in a try-except block to log unexpected exceptions instead of raising them. This prevents cleanup exceptions from masking other active exceptions raised during main processing.

Comment thread src/acp/conductor.py
Comment on lines +293 to +295
if self._exit_stack is not None:
await self._exit_stack.aclose()
self._exit_stack = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Wrap the exit stack cleanup in a try-except block to log unexpected exceptions instead of raising them. If aclose() raises an exception here, it will propagate and completely mask the original exception from _initialize_chain(), making debugging extremely difficult.

Suggested change
if self._exit_stack is not None:
await self._exit_stack.aclose()
self._exit_stack = None
if self._exit_stack is not None:
try:
await self._exit_stack.aclose()
except Exception:
logger.exception("Failed to close exit stack during error cleanup")
self._exit_stack = None
References
  1. Wrap cleanup operations (such as unsubscribing, closing connections, or releasing resources) inside finally blocks in a try-except block to log unexpected exceptions instead of raising them. This prevents cleanup exceptions from masking other active exceptions raised during main processing.

Comment on lines +481 to 490
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Wrap each cleanup operation in a try-except-finally block to log unexpected exceptions instead of raising them. This ensures that all cleanup steps (conductor, tool bridge, and client handler) are executed even if one of them fails, preventing resource leaks and exception masking.

        if self._conductor is not None:
            try:
                await self._conductor.__aexit__(None, None, None)
            except Exception:
                self.log.exception("Error during conductor cleanup")
            finally:
                self._conductor = None

        if self._tool_bridge is not None:
            try:
                await self._tool_bridge.stop()
            except Exception:
                self.log.exception("Error during tool bridge cleanup")
            finally:
                self._tool_bridge = None

        self._extra_toolsets.clear()
        self._extra_mcp_servers.clear()

        if self._client_handler:
            try:
                await self._client_handler.cleanup()
            except Exception:
                self.log.exception("Error during client handler cleanup")
References
  1. Wrap cleanup operations (such as unsubscribing, closing connections, or releasing resources) inside finally blocks in a try-except block to log unexpected exceptions instead of raising them. This prevents cleanup exceptions from masking other active exceptions raised during main processing.
  2. When closing multiple connections or resources in a loop, wrap each close operation in a try-except block to ensure that a failure in closing one resource does not prevent other active resources from being closed.

Comment thread src/agentpool/agents/acp_agent/acp_agent.py Outdated
Comment thread src/agentpool/agents/acp_agent/acp_converters.py Outdated
Leoyzen added 3 commits July 8, 2026 19:18
…kProxy params[key], meta propagation, cleanup on init failure
…onductor

TDD-driven fix for the #1 CRITICAL review comment: proxy chain bypass.

Problem: ACPClientAdapter.prompt() called api.prompt() directly, bypassing
the proxy chain. Additionally, _initialize() created a duplicate
ClientSideConnection, overwriting Conductor's connection.

Solution:
- ACPClientAdapter now accepts optional 'conductor' parameter
- When conductor is present and intercepts 'session/prompt', prompt()
  routes through conductor._route_to_terminal() instead of api.prompt()
- stop_reason extracted from conductor response dict
- _initialize() no longer creates duplicate connection when Conductor
  already set self._connection (guards with 'if self._connection is None')
- _setup_conductor() creates ACPState + ACPClientHandler BEFORE entering
  Conductor (solves chicken-and-egg: Conductor needs handler for notifications)
- create_turn() passes conductor to adapter

Tests: 4 new TDD tests in test_adapter_proxy_routing.py:
- prompt() routes through conductor when conductor is present
- prompt() falls back to api.prompt without conductor
- stop_reason extracted from conductor response
- prompt() falls back when conductor doesn't intercept
…AP, cleanup try-except

Round 4 review fixes:
- Remove unused _TerminalConnectionAdapter class
- Replace logging/structlog with agentpool.log.get_logger in conductor.py, hook_proxy.py, context_injection.py, tool_provider.py
- Simplify to_finish_reason() to use STOP_REASON_MAP.get() instead of manual loop
@Leoyzen

Leoyzen commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Round 4 Review Response Summary

All 16 new Gemini review comments have been addressed in commit 25a359cdc:

✅ Fixed & Resolved (9 threads)

Thread Issue Fix
#18, #32 _TerminalConnectionAdapter unused Removed in 25a359c
#19 known_types empty in ProxyChainConfig Fixed in 80941b9 (frozenset({"hook", "context_injection", "tool_provider"}))
#20 HookProxy hardcoded params["content"] Fixed in 49880a3 (uses params[key] where key = "prompt" if "prompt" in params else "content")
#25 structlog import in conductor.py Replaced with from agentpool.log import get_logger in 25a359c
#26-28 logging import in proxy impls Replaced with from agentpool.log import get_logger in 25a359c
#33 STOP_REASON_MAP manual loop Simplified to .get() in 25a359c

📝 Acknowledged — Will Address in Follow-up (7 threads)

Thread Issue Plan
#21 Background task GC risk Valid concern — will add module-level task set for strong references
#22-24 Adapter stream_events simplification Good suggestions for sentinel-based approach — will refactor in follow-up
#29-31 Wrap cleanup in try-except Will add defensive try-except around exit stack cleanup

These are all MEDIUM severity and don't affect correctness. The critical/high issues from all review rounds have been resolved.

@Million-mo

Copy link
Copy Markdown
Collaborator

PR 评估:ACP Proxy Chain 重构

总体评价

这是一次架构级重构,用 Proxy Chain + Conductor 模式替代了单体 ACPAgent。PR 解决了三个具体的架构缺陷:ACPTurn 死代码、50ms 轮询循环、以及嵌套 ACP 的 ~1600 行冗余事件转换。设计文档详尽,OpenSpec 产物完整,经过了 4 轮 Gemini 审查且作者均有回应。

质量信号:lint 通过(ruff + mypy),无 as any/cast/getattr/hasattr,测试覆盖真实(374 个测试,每个新组件有对应测试文件)。

优点

  1. 清晰的架构分离Proxy protocol + Conductor + ACPClientAdapter 各有单一职责。Proxy chain 可组合——加 HookProxyContextInjectionProxy 不需要改 Conductor。

  2. Zero-conversion passthrough 是正确的优化——ACP→native→ACP 的双向转换在语义上就是多余的。PassthroughEventConverter yield nothing 但仍 tracking usage 是干净的设计。

  3. 测试与代码同文件:每个新模块都有对应测试文件(test_conductor.pytest_adapter.pytest_hook_proxy.py 等),测试验证行为而非结构。

  4. HookProxy/HookAwareTurn 共存处理得当——_hooks=None guard + set_hooks_enabled(False) 避免双触发。

关注点

  1. PR 体量是 review 风险:6338 行放一个 PR 里很难彻底 review。Conductor 单文件 870 行。虽然 phases 划分清晰,但理想情况应拆成 2-3 个 PR。

  2. Reverse proxy routing 是半成品_route_message 有注释说"full reverse proxy routing will be implemented when needed"。response 阶段确实在 _route_to_terminal 内逆序遍历 proxy,但 _route_message 自身对 reverse 方向只是原样返回。对 post_turn hooks 能用但脆弱。

  3. Gemini 审查标记了早期 critical bugs:重复 subprocess spawn、proxy chain bypass、ProxyChainConfig 拒绝所有类型、HookProxy 硬编码 params["content"] key。作者都修了,但说明初始版本缺集成测试。后续轮次测试覆盖更好。

  4. ToolProviderProxy 是 passthrough stub:在 proxy_initialize() 声明拦截 ["session/prompt"]proxy_successor 原样返回 params——这会强制 Conductor 走 proxy forward 路径但什么都不做,是不必要的开销。建议在实现前返回 [](零拦截)。

  5. 删除 .omo/plans/fix-mcp-session-lifecycle.md(407 行):不相关清理混入本 PR,轻微 pollutes diff。

  6. 7 个 medium 已知跟进项:background task GC 风险、adapter sentinel 简化、cleanup try-except wrapping。作者已承认并 deferred。background task GC 如果确实存在(asyncio.Task 未持有强引用),可能导致间歇性 task 取消——值得确认是否纯理论。

  7. Conductor._execute_step 硬编码 prompt 为 [{"type": "text", "text": prompt_text}]——直接耦合 ACP wire format。

结论

Approve with follow-up。 核心架构合理且比旧方案有实质改进。Proxy chain 模式可扩展,zero-conversion passthrough 方向正确,测试是真实的。主要风险是体量(应拆分)和 reverse routing 不完整。建议跟进:

  • 为 reverse proxy routing 路径加集成测试,锁定当前行为
  • ToolProviderProxy.proxy_initialize() 在实现前返回 []
  • 在 follow-up PR 中解决 background task GC 问题
  • 补一个真实 ACP agent(非 mock)的端到端 smoke test

@Million-mo Million-mo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. See detailed evaluation in the comment above. Core architecture is sound — proxy chain + Conductor is a substantial improvement over the monolithic ACPAgent. Three concerns to track as follow-up: (1) reverse proxy routing is partial, needs integration tests; (2) ToolProviderProxy should return [] from proxy_initialize until implemented; (3) background task GC risk needs confirmation. None of these block merge.

@Million-mo

Copy link
Copy Markdown
Collaborator

@Leoyzen 想跟你讨论一下 Proxy Chain + Conductor 的架构价值,有几个点想听你的想法:

核心疑问:三个 bug 是否需要 proxy chain 来修?

PR 声明要解决的三个问题,看起来都有更直接的修法:

问题 Proxy Chain 方案 更简方案
ACPTurn 死代码 引入整个架构 只写 ACPClientAdapter(254 行)
50ms 轮询 Conductor + 适配器 session_update() 推异步队列(~20 行)
1600 行冗余转换 PassthroughEventConverter 已经是独立组件,不需要 proxy chain

这三个修复加起来约 300 行代码,而 Conductor 单文件 870 行,加上 proxy 包总共约 1600 行新代码——新代码比它修复的 bug 还多。

具体关注点

1. HookProxy vs HookAwareTurn 双系统

代码库已有 HookAwareTurn mixin 原生触发 hooks(类型安全,直接在 Python 方法签名上操作)。HookProxy 把同样的逻辑通过 JSON-RPC 消息拦截重新实现了一遍,用 dict[str, Any] 传递参数,丢失了类型安全。PR 不得不用 _hooks=None guard 和 set_hooks_enabled(False) 来防止双触发——这是典型的"两个系统做同一件事加兼容层"的 code smell。

为什么不在 HookAwareTurn 里直接扩展对 ACP turn 的支持,而是另起一套 proxy?

2. Proxy protocol 用 wire protocol 风格调用进程内对象

Proxy protocol 的 proxy_successor(method, params, meta) 是 JSON-RPC wire protocol 风格。但所有 proxy 都是进程内 Python 对象,没有跨进程通信。用 wire protocol 风格接口调用 Python 函数,多了一层序列化心智负担,却没得到 wire protocol 的好处(跨进程、跨语言)。

如果 proxy 是远程的(在另一个子进程里),这个协议就有意义。当前不是。这个设计选择是出于对未来跨进程 proxy 的预期吗?

3. ContextInjectionProxy 的必要性

本质是往 prompt 前面拼一段 AGENTS.md 内容。这可以是 system prompt 模板里的一行 Jinja2,或者 prompt pipeline 里的一个 step。通过 JSON-RPC 方法拦截来做字符串拼接是否过重?

4. ToolProviderProxy 的开销

它是 stub,什么都不做,但在 proxy_initialize() 声明拦截 session/prompt,强制 Conductor 走完整 forward 路径然后原样返回——纯运行时开销。为什么不在实现前返回 [](零拦截)?

5. Reverse routing 暴露的设计错配

Proxy chain 天然适合单向 forward 拦截。但 hooks 需要双向:forward 阶段触发 pre_turn/pre_tool,reverse 阶段触发 post_turn/post_tool。PR 把 reverse routing 塞进 _route_to_terminal 作为特殊情况,_route_message 自身对 reverse 方向只是原样返回。这是否说明 proxy chain 模式和 hook 的双向需求天然不匹配?

6. Conductor 870 行的维护负担

大部分代码是链初始化、消息路由、生命周期管理、passthrough 优化——本质是一个 mini RPC middleware framework。但当前只有 3 个 proxy(1 个是 stub),且不打算让用户动态组合(YAML 写死)。registry 模式在 3 个固定实现上没有回报。

什么情况下 Proxy Chain 才真正有价值

  • Proxy 跨进程(每个 proxy 是独立 ACP 子进程)→ Conductor 的连接管理、消息路由、错误传播都有意义
  • 5+ 个可组合 proxy → registry 模式才有回报
  • ACP 规范要求合规 → 实现是为了合规

当前这三个条件是否满足?如果都不满足,是否应该先采用更简方案修复 bug,等真正需要跨进程 proxy 动态组合时再引入 Conductor?


想听你的设计决策背景——是否有我看不到的约束或未来规划使得 Proxy Chain + Conductor 是必要的选择?

@Leoyzen
Leoyzen marked this pull request as draft July 13, 2026 06:27
@Leoyzen

Leoyzen commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

感谢深度的 review,两个维度的分析都很有价值。回复一下架构方向和技术细节上的判断。

原则

我们在准备 v1.0 正式版本,核心原则有三条:

  1. 消除技术债,尤其是历史错误的架构。monolithic ACPAgent 就是这样的债——它是在 proxy chain 概念出现前写的 adhoc 抽象,三个 bug 不是偶发的,是结构性缺陷的症状。
  2. 紧贴 pydantic-ai 和 ACP 的最优实践与规划实践。ACP proxy chain RFD 是 ACP 维护者定义的官方架构模式,已有 Rust 参考实现。实现 RFD 不是"发明架构",是"对齐规范"。为后续扩展留出空间是 v1.0 的必要投资。
  3. ACP proxy chain 很可能比自维护 ACP agent 抽象更划算。当前 ACP agent 几乎没人用,索性用 proxy chain 替换掉,风险低、收益高。

在这个框架下,"300 行修 3 个 bug"的方案被否决——因为它保留了 adhoc 的 monolithic 抽象,修了 bug 但留下了债。

技术细节:辩证采纳

你的部分技术关注点确实成立,会在后续处理:

关注点 判断 处理
ToolProviderProxy stub 纯开销 ✅ 同意 实现前返回 [](零拦截)
Reverse routing 半成品 ✅ 同意 补集成测试锁定当前行为,完善 _route_message
Background task GC 风险 ✅ 值得确认 会验证是否纯理论,必要时加 module-level task set
PR 体量过大 ✅ 同意 已通过拆分解决(见下文)
ContextInjectionProxy 过重 部分同意 当前实现紧贴 RFD 的 proxy 拦截语义,但会评估是否有更轻量的表达方式

以下几点我们认为架构方向上是对的,保留当前设计:

HookProxy vs HookAwareTurn 双系统:这是过渡态,不是终态。Design doc D9 的 migration path 末尾明确写了:"Eventually, when all ACP agents use Conductor, HookAwareTurn on ACPTurn can be removed (kept only for NativeTurn)." _hooks=None guard 是过渡期的兼容层,不是永久设计。选择 adapter pattern 而非重写 hooks,是为了保留已测试的 CallableHook/CommandHook/PromptHook 实现。

Wire protocol 风格用于进程内对象:这是 RFD 定义的接口形态。紧贴 spec 就意味着用 proxy/successor 的 method/params/meta 风格,即使当前是进程内调用。如果未来 proxy 运行在独立子进程中(ACP remote transport 是 RFD 的规划方向),接口不需要改。

Conductor 870 行:大部分是链初始化、消息路由、生命周期管理——这些是 RFD Conductor pattern 的固有复杂度,不是过度设计。Registry 模式在 3 个 proxy 上的确没有即时回报,但 v1.0 的定位是为后续扩展留空间。

PR 拆分策略

这个 PR 会保持 draft,按以下顺序推进:

PR #144 (refactor/agentwolf_v1)     ← v1 基线重构,draft
  └── PR #147 (pre-m4-protocol-cleanup)  ← 已含 Phase 1(adapter + ACPTurn fix + polling fix)
        └── PR #127 (acp-proxy-chain-refactor)  ← rebase 后只剩 Phase 2-6(Conductor + Proxy)

PR #147 已经吸收了你说的"300 行修 bug"那部分工作:

  • ACPAgentAPI adapter(stream_events() + get_messages()
  • ACPTurn 委托替代 200 行 inline _stream_events()
  • Hook firing 清理(移除 ACP 双路径分支)
  • 统一 prompt 路由

同时 #147 还做了大量技术债清理(RunStatus 移除、HostContext.pool 移除、type: ignore 消除、hasattr 替换等),和消除技术债的原则一致。

#144#147 land 后,#127 rebase 到干净基线上,只剩 Conductor + Proxy 架构本身。届时 review 范围会大幅缩小。


总结:架构方向上我们一致采用 proxy chain,不做 adhoc 修复。你的技术细节关注点(ToolProviderProxy、reverse routing、GC 风险)会在后续处理。PR sequencing 已经解决了体量问题。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Audit: Dead Code, Wrapper Collapse, and Oversized Files

2 participants