feat(acp): ACP proxy chain refactor — Conductor, HookProxy, zero-conversion passthrough - #127
feat(acp): ACP proxy chain refactor — Conductor, HookProxy, zero-conversion passthrough#127Leoyzen wants to merge 50 commits into
Conversation
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
… .openspec.yaml status (Todo 13)
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
There was a problem hiding this comment.
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.
…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)
…ation_ms to post_turn hooks (PR #125 review)
…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
|
/gemini review |
|
Warning Gemini encountered an error creating the review. You can try again by commenting |
|
/gemini review |
|
Warning Gemini encountered an error creating the review. You can try again by commenting |
|
/gemini review |
There was a problem hiding this comment.
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.
…verse routing, ACP param names, CancelledError
|
/gemini review |
|
Warning Gemini encountered an error creating the review. You can try again by commenting |
|
/gemini review |
There was a problem hiding this comment.
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.
|
/gemini review |
There was a problem hiding this comment.
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.
| self._prompt_response = response | ||
| return response | ||
|
|
||
| self._prompt_task = asyncio.create_task(_run_prompt()) |
There was a problem hiding this comment.
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.
| 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
- 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.
| import asyncio | ||
| from typing import TYPE_CHECKING |
There was a problem hiding this comment.
Define a module-level set to maintain strong references to active background tasks, preventing them from being garbage collected mid-execution.
| import asyncio | |
| from typing import TYPE_CHECKING | |
| import asyncio | |
| from typing import TYPE_CHECKING, Any | |
| _background_tasks: set[asyncio.Task[Any]] = set() |
References
- 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.
| 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 |
There was a problem hiding this comment.
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.
| 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) |
| if self._exit_stack is not None: | ||
| await self._exit_stack.aclose() | ||
| self._exit_stack = None |
There was a problem hiding this comment.
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.
| 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
- Wrap cleanup operations (such as unsubscribing, closing connections, or releasing resources) inside
finallyblocks in atry-exceptblock to log unexpected exceptions instead of raising them. This prevents cleanup exceptions from masking other active exceptions raised during main processing.
| if self._exit_stack is not None: | ||
| await self._exit_stack.aclose() | ||
| self._exit_stack = None |
There was a problem hiding this comment.
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.
| 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
- Wrap cleanup operations (such as unsubscribing, closing connections, or releasing resources) inside
finallyblocks in atry-exceptblock to log unexpected exceptions instead of raising them. This prevents cleanup exceptions from masking other active exceptions raised during main processing.
| 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() |
There was a problem hiding this comment.
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
- Wrap cleanup operations (such as unsubscribing, closing connections, or releasing resources) inside
finallyblocks in atry-exceptblock to log unexpected exceptions instead of raising them. This prevents cleanup exceptions from masking other active exceptions raised during main processing. - 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.
…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
Round 4 Review Response SummaryAll 16 new Gemini review comments have been addressed in commit ✅ Fixed & Resolved (9 threads)
📝 Acknowledged — Will Address in Follow-up (7 threads)
These are all MEDIUM severity and don't affect correctness. The critical/high issues from all review rounds have been resolved. |
PR 评估:ACP Proxy Chain 重构总体评价这是一次架构级重构,用 Proxy Chain + Conductor 模式替代了单体 质量信号:lint 通过(ruff + mypy),无 优点
关注点
结论Approve with follow-up。 核心架构合理且比旧方案有实质改进。Proxy chain 模式可扩展,zero-conversion passthrough 方向正确,测试是真实的。主要风险是体量(应拆分)和 reverse routing 不完整。建议跟进:
|
Million-mo
left a comment
There was a problem hiding this comment.
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.
|
@Leoyzen 想跟你讨论一下 Proxy Chain + Conductor 的架构价值,有几个点想听你的想法: 核心疑问:三个 bug 是否需要 proxy chain 来修?PR 声明要解决的三个问题,看起来都有更直接的修法:
这三个修复加起来约 300 行代码,而 Conductor 单文件 870 行,加上 proxy 包总共约 1600 行新代码——新代码比它修复的 bug 还多。 具体关注点1. HookProxy vs HookAwareTurn 双系统代码库已有 为什么不在 HookAwareTurn 里直接扩展对 ACP turn 的支持,而是另起一套 proxy? 2. Proxy protocol 用 wire protocol 风格调用进程内对象
如果 proxy 是远程的(在另一个子进程里),这个协议就有意义。当前不是。这个设计选择是出于对未来跨进程 proxy 的预期吗? 3. ContextInjectionProxy 的必要性本质是往 prompt 前面拼一段 AGENTS.md 内容。这可以是 system prompt 模板里的一行 Jinja2,或者 prompt pipeline 里的一个 step。通过 JSON-RPC 方法拦截来做字符串拼接是否过重? 4. ToolProviderProxy 的开销它是 stub,什么都不做,但在 5. Reverse routing 暴露的设计错配Proxy chain 天然适合单向 forward 拦截。但 hooks 需要双向:forward 阶段触发 pre_turn/pre_tool,reverse 阶段触发 post_turn/post_tool。PR 把 reverse routing 塞进 6. Conductor 870 行的维护负担大部分代码是链初始化、消息路由、生命周期管理、passthrough 优化——本质是一个 mini RPC middleware framework。但当前只有 3 个 proxy(1 个是 stub),且不打算让用户动态组合(YAML 写死)。registry 模式在 3 个固定实现上没有回报。 什么情况下 Proxy Chain 才真正有价值
当前这三个条件是否满足?如果都不满足,是否应该先采用更简方案修复 bug,等真正需要跨进程 proxy 动态组合时再引入 Conductor? 想听你的设计决策背景——是否有我看不到的约束或未来规划使得 Proxy Chain + Conductor 是必要的选择? |
|
感谢深度的 review,两个维度的分析都很有价值。回复一下架构方向和技术细节上的判断。 原则我们在准备 v1.0 正式版本,核心原则有三条:
在这个框架下,"300 行修 3 个 bug"的方案被否决——因为它保留了 adhoc 的 monolithic 抽象,修了 bug 但留下了债。 技术细节:辩证采纳你的部分技术关注点确实成立,会在后续处理:
以下几点我们认为架构方向上是对的,保留当前设计: 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)." Wire protocol 风格用于进程内对象:这是 RFD 定义的接口形态。紧贴 spec 就意味着用 Conductor 870 行:大部分是链初始化、消息路由、生命周期管理——这些是 RFD Conductor pattern 的固有复杂度,不是过度设计。Registry 模式在 3 个 proxy 上的确没有即时回报,但 v1.0 的定位是为后续扩展留空间。 PR 拆分策略这个 PR 会保持 draft,按以下顺序推进: PR #147 已经吸收了你说的"300 行修 bug"那部分工作:
同时 #147 还做了大量技术债清理( 等 #144 和 #147 land 后,#127 rebase 到干净基线上,只剩 Conductor + Proxy 架构本身。届时 review 范围会大幅缩小。 总结:架构方向上我们一致采用 proxy chain,不做 adhoc 修复。你的技术细节关注点(ToolProviderProxy、reverse routing、GC 风险)会在后续处理。PR sequencing 已经解决了体量问题。 |
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
ACPAgentimplementation conflates subprocess management, ACP client communication, and event conversion into a single monolithic class with three critical issues:ACPTurnis dead code — the designed Turn abstraction was non-functional due to a missing adapterSolution
Implements the proxy chain RFD conductor pattern across 7 phases (25 tasks):
Phase 1 (T0-T6): ACPClientAdapter + ACPTurn revival
ACPClientAdapterbridges blockingACPAgentAPI.prompt()to non-blockingACPClientProtocol(fire-and-forget + async queue)ACPClientHandler.session_update()bifurcated: state updates in-place, stream data to async queueACPSessionStaterenamed toACPState, deque removed_stream_events()delegates toACPTurn.execute()— no more pollingPhase 2 (T7-T12): Conductor + Proxy protocol
src/acp/proxy/package:Proxyprotocol,ProxySideConnection, wire constantsConductorclass (src/acp/conductor.py): MessageNode inheritance, AsyncExitStack lifecycle, chain initialization, message routing with passthrough optimization, error propagationPhase 3 (T13-T16): ACPAgent rewrite
str→ChatMessage[str]__init__acceptsproxy_chainandagent_hooks(auto-inserts HookProxy)ProxyChainConfigmodel,get_tool_factories()(ToolsetFactory migration)Phase 4 (T17-T21): Built-in proxy implementations
HookProxy: wrapsAgentHooks, maps all 4 hook types (pre_turn, post_turn, pre_tool_use, post_tool_use)ContextInjectionProxy: prepends AGENTS.md + skill instructionsToolProviderProxy: experimental MCP-over-ACP stub_hooks=Noneguard,set_hooks_enabled(False)on handlerPhase 5 (T22-T23): Server-side adaptation
process_prompt()dual path removed — consolidated toACPProtocolHandler.handle_prompt()ACPEventConvertersplit: 8 stateless functions +PassthroughEventConverter(zero-conversion)EventConverterComponentprotocol for proxy-wrappable convertersPhase 6 (T24): Cleanup
use_conductorfeature flag removed (Conductor is only path)Test plan
tests/agents/acp_agent/,tests/acp/,tests/servers/acp_server/)uv run ruff check src/— cleanuv run --no-group docs mypy src/acp/— cleancast()/getattr/hasattrin new filesKey files
src/acp/conductor.pysrc/acp/proxy/protocol.pysrc/acp/proxy/impls/hook_proxy.pysrc/acp/proxy/impls/context_injection.pysrc/acp/proxy/impls/tool_provider.pysrc/agentpool/agents/acp_agent/adapter.pysrc/agentpool_server/acp_server/event_converter.pyOpenSpec
Change:
openspec/changes/acp-proxy-chain-refactor/(proposal, design, 6 specs, tasks)