Skip to content

feat: Durable Elicitation Bridge — checkpoint/resume for elicitation requests - #110

Merged
Leoyzen merged 38 commits into
develop/agenticfrom
feature/durable-elicitation-bridge
Jul 7, 2026
Merged

feat: Durable Elicitation Bridge — checkpoint/resume for elicitation requests#110
Leoyzen merged 38 commits into
develop/agenticfrom
feature/durable-elicitation-bridge

Conversation

@Leoyzen

@Leoyzen Leoyzen commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements durable elicitation: elicitation requests (form-based "ask the user" prompts) from both MCP tools and local tools survive process crashes via checkpoint/resume infrastructure.

Closes #107

Architecture

Two Resolution Paths

Local tools (e.g. question_for_user): Inline future resolution — handle_elicitation() checkpoints, emits ElicitationDeferredEvent, registers an asyncio.Future, and awaits it. The agent run suspends (not ends) at the await point. When the user responds, resume_session() resolves the future, handle_elicitation() returns the response, the tool function continues naturally. Zero re-execution.

MCP tools: handle_elicitation() raises CallDeferred (FastMCP's callback wrapper catches exceptions, so the MCP elicitation_handler in client.py converts to side-channel + sentinel). call_tool() re-raises CallDeferred after the MCP call returns. The run ends with DeferredToolRequests and resumes via crash recovery with cached_elicitation_responses.

Key Components

  • handle_elicitation() in context.py: Three-path dispatch — crash recovery (cached response), MCP tools (raise CallDeferred), local tools (await future)
  • ElicitationDeferredBridge: HandleDeferredToolCalls capability wired after deferred_bridge, before approval_bridge. Checkpoints session, emits event, registers future
  • ElicitationFutureRegistry: Per-session future management with register(), resolve(), remove(), reject_all()
  • ACP handler: Intercepts ElicitationDeferredEvent, sends elicitation/create request to client (two-way), collects response, calls resume_session()
  • Crash recovery: resume_session()_resume_native_agent() re-creates agent, sets cached_elicitation_responses, calls agent.run_stream(). During re-execution, handle_elicitation() returns cached response
  • Auto-enable: checkpoint_enabled set to True when SessionController.store is not None
  • ElicitationResolutionStrategy: Abstraction with CheckpointResolutionStrategy (current) + ProtocolResolutionStrategy (MRTR placeholder)

Session Status Fix

_with_resume_lock now allows "active" session status when allow_active_run=True, because the elicitation bridge saves checkpoint data without updating the session store status from "active" to "checkpointed".

Resume Notification

SessionResumeEvent is logged backend-only — no ACP notification sent to frontend. Streaming events from the resuming agent run are sufficient.

Files Changed (34 files, +4000/-92 lines)

New files:

  • src/agentpool/agents/native_agent/elicitation_strategy.py — Strategy protocol + implementations
  • src/agentpool/agents/native_agent/elicitation_bridge.py — Bridge capability + future registry
  • tests/elicitation/test_unit_elicitation.py — 19 unit tests
  • tests/elicitation/test_integration_elicitation.py — 10 integration tests
  • tests/elicitation/test_protocol_integration.py — 10 protocol integration tests
  • tests/elicitation/test_resume_lifecycle.py — Resume lifecycle tests
  • openspec/changes/durable-elicitation-bridge/ — OpenSpec change artifacts

Modified files:

  • src/agentpool/sessions/models.pyPendingDeferredCall extension, ElicitationResumePayload
  • src/agentpool/agents/events/events.pyElicitationDeferredEvent
  • src/agentpool/agents/context.py — Three-path handle_elicitation(), CallDeferred, cached responses, future await
  • src/agentpool/mcp_server/client.py — Side-channel + CallDeferred re-raise
  • src/agentpool/agents/native_agent/agent.py — Bridge wiring, DeferredToolRequests in output_type
  • src/agentpool/orchestrator/core.pyresume_session(), in-process + crash recovery, SessionClosedError, auto-enable, concurrent-run guard
  • src/agentpool/ui/base.pysupports_durable_elicitation property
  • src/agentpool_server/acp_server/checkpoint_enabled, dynamic property, event converter, elicitation handler
  • src/agentpool_server/opencode_server/ — Dynamic property, event processor
  • docs/architecture/user_interaction.md — +103 lines documenting the flow

Test Results

tests/elicitation/:           42 passed
tests/orchestrator/:         460 passed, 7 skipped
  • uv run ruff check src/ — All checks passed
  • uv run ruff format --check src/ — All files formatted
  • uv run mypy src/ — 0 errors

PR Review Fixes

# File Issue Fix
1 context.py Future not cleaned up on timeout/cancel Added finally: registry.remove(handle)
2 core.py Concurrent run if in-process resolution fails Added SessionBusyError guard before crash recovery
3 handler.py CancelledError swallowed Re-raise instead (user cancel comes as action="cancel" response)
4 client.py agent_ctx could be None Invalid — if agent_ctx: guard already prevents handler definition
5 core.py Comment says "filter" but no filtering Fixed comment — API contract separates payloads

Guardrails Verified

  • ✅ No FastMCP modifications (only MCPClient.call_tool() wrapper changed)
  • ✅ No MRTR implementation (ProtocolResolutionStrategy raises NotImplementedError)
  • ✅ Synchronous elicitation path intact (when supports_durable_elicitation=False)
  • ✅ No getattr/hasattr — full type safety
  • ✅ No TODOs in implementation code

Refs: #107

Leoyzen added 12 commits July 5, 2026 20:45
…tationResumePayload and ElicitationDeferredEvent

- Extend deferred_kind with 'elicitation' variant
- Add optional fields: elicitation_message, elicitation_schema, elicitation_mode, mcp_server_id
- Add ElicitationResumePayload Schema for accept/decline/cancel responses
- Add ElicitationDeferredEvent dataclass to RichAgentStreamEvent union
- Serialization backward-compatible (optional fields default to None)

Refs: #107
…kpoint and protocol placeholders

- Define ElicitationResolutionStrategy Protocol with async resolve() method
- Implement CheckpointResolutionStrategy delegating to CheckpointManager.checkpoint()
- Add ProtocolResolutionStrategy placeholder for future MRTR/SEP-2663 support

Refs: #107
…and implementations

- Add supports_durable_elicitation property to InputProvider base class
  (defaults to False; StdlibInputProvider and MockInputProvider inherit
  this default)
- Add dynamic property to ACPInputProvider checking
  self.session.checkpoint_enabled at runtime
- Add dynamic property to OpenCodeInputProvider checking session
  checkpoint config via session controller at runtime
- Add checkpoint_enabled field to ACPSession and SessionState (default
  False) as foundation for runtime checks

Refs: #107
…el and side-channel

Add _pending_elicitation_deferral side-channel to AgentContext for
durable elicitation. When provider.supports_durable_elicitation is True,
handle_elicitation() stores params and returns a sentinel decline result
instead of calling get_elicitation() directly. MCPClient.call_tool()
checks the side-channel after the MCP call returns and raises
CallDeferred with elicitation metadata, enabling checkpoint-based
deferral.

Key design decisions:
- Sentinel return (decline) prevents FastMCP from blocking on the handler
- Side-channel dict on AgentContext avoids exception-based control flow
  inside the elicitation callback (FastMCP catches exceptions)
- except CallDeferred: raise placed BEFORE broad except Exception to
  prevent CallDeferred from being swallowed into RuntimeError
- Exhaustive match on ElicitRequestParams union (FormParams | URLParams)
  with no getattr/hasattr

Refs: #107
…nFutureRegistry, wire into capability chain

- Create elicitation_bridge.py with ElicitationDeferredBridge as a
  HandleDeferredToolCalls capability following the deferred_bridge/approval_bridge
  factory function pattern
- Implement ElicitationFutureRegistry: per-session registry of asyncio.Future
  instances with register/resolve/reject_all lifecycle methods
- Bridge handler inspects DeferredToolRequests.calls for entries with
  metadata['deferred_kind'] == 'elicitation', checkpoints via
  CheckpointManager, emits ElicitationDeferredEvent, registers future,
  and returns None (block). Non-matching calls pass through.
- Wire elicitation bridge into get_agentlet() capability chain AFTER
  deferred_bridge and BEFORE approval_bridge

Refs: #107
…ode converters

- Mark ElicitationDeferredEvent as immediate in _is_immediate() in core.py
- ACP event_converter: emit ToolCallStart with elicitation params in _meta
- OpenCode event_processor: create ToolPart with elicitation metadata

Refs: #107
…th in-process and crash recovery paths

- Add elicitation_payloads parameter to resume_session() (optional, default None)
- Add _try_in_process_elicitation_resume() helper: resolves futures in
  ElicitationFutureRegistry when agent run is still alive (in-process)
- Extend _resume_native_agent() with crash recovery: pre-populates
  cached_elicitation_responses on AgentRunContext so handle_elicitation()
  returns cached response during MCP tool re-execution
- Add elicitation_registry and cached_elicitation_responses fields to
  AgentRunContext
- Update handle_elicitation() to check cached responses before deferring
- Store ElicitationFutureRegistry on run_ctx in get_agentlet()
- Add __contains__ to ElicitationFutureRegistry for membership checking
- Add SessionClosedError exception
- Call registry.reject_all(SessionClosedError()) on session close
- Validate elicitation payloads cover all elicitation deferred calls
- Exclude elicitation calls from deferred_tool_results validation

Refs: #107
Covers Tasks 9.1-9.7, 9.14-9.16, 9.18-9.19, 9.21-9.22:
- PendingDeferredCall serialization roundtrip (with/without elicitation fields)
- handle_elicitation durable=True (side-channel + decline sentinel)
- handle_elicitation durable=False (direct get_elicitation call)
- MCPClient.call_tool raises CallDeferred when side-channel is set
- MCPClient.call_tool normal path (no deferral)
- Elicitation bridge handles elicitation + passthrough non-elicitation calls
- ElicitationFutureRegistry register/resolve/reject_all lifecycle
- ACPInputProvider.supports_durable_elicitation dynamic property
- OpenCodeInputProvider.supports_durable_elicitation dynamic property
- Side-channel cleanup on error (finally block clears _current_elicitation_handler)
- Bridge positioning: elicitation handled before approval
- Backward compatibility: old-format PendingDeferredCall deserialization
- ElicitationResumePayload decline/cancel/accept actions + cached response
- Strategy classes: CheckpointResolutionStrategy, ProtocolResolutionStrategy
- Elicitation timeout field serialization/deserialization

Refs: #107
…exts

AsyncMock returns truthy mocks for any attribute, which incorrectly
triggered the CallDeferred side-channel check in MCPClient.call_tool().

Refs: #107
Task 7 changed _resume_native_agent() to call agent.run_stream() instead
of agent.run(). Updated test mocks to use async generators instead of
AsyncMock to properly match the async iterator interface.

Refs: #107

@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 durable elicitation to enable agent sessions to survive process crashes during user interaction. It introduces a two-level interception mechanism using a sentinel decline and a side-channel to raise CallDeferred, checkpoints session state, and supports both in-process and crash recovery resume paths. The review feedback highlights two critical issues: first, calling checkpoint inside a loop in elicitation_bridge.py will overwrite checkpoints when multiple parallel tool calls trigger elicitation; second, resume_session will unconditionally raise SessionBusyError during in-process resume because session.current_run_id is active, blocking the in-process path.

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/native_agent/elicitation_bridge.py
Comment thread src/agentpool/orchestrator/core.py Outdated
Design correction:
- handle_elicitation() now raises CallDeferred directly (not side-channel)
- MCP elicitation_handler catches CallDeferred, converts to side-channel
  (FastMCP workaround isolated to MCP client only)
- Local tools like question_for_user need zero adaptation

PR review fixes:
- #1: Accumulate all pending_calls, single checkpoint after loop
  (prevents overwrite when multiple parallel elicitation calls)
- #2: Bypass SessionBusyError for in-process elicitation resume
  (add allow_active_run parameter to _with_resume_lock)

CI fixes:
- ruff: D202 blank line after docstring, D104 missing package docstring
- ruff format: 3 files reformatted
- snapshot: update ACP event converter snapshot (pre-existing drift)

Refs: #107
@Leoyzen

Leoyzen commented Jul 5, 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 durable elicitation, enabling agent sessions to survive process crashes during user interaction. It introduces a two-level interception mechanism using CallDeferred and a side-channel workaround for FastMCP, along with an ElicitationDeferredBridge capability and ElicitationFutureRegistry to manage checkpointing and session resumption (both in-process and via crash recovery). Additionally, the ACP and OpenCode providers are updated to support durable elicitation, and extensive unit and integration tests are added. Feedback on the changes suggests delegating the lifecycle management of the agent resource (such as calling __aexit__) to its factory function rather than manually handling it at the call site in _resume_native_agent.

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/orchestrator/core.py Outdated
Leoyzen added 9 commits July 5, 2026 22:12
- test_approval_bridge: expect 3 HandleDeferredToolCalls (deferred + elicitation + approval)
- test_capability_chain: patch elicitation bridge, verify deferred < elicitation < approval order

Refs: #107
_ACPSessionProxy is used by ACPProtocolHandler when creating
ACPInputProvider for elicitation. Without checkpoint_enabled,
ACPInputProvider.supports_durable_elicitation raises
AttributeError: '_ACPSessionProxy' object has no attribute
'checkpoint_enabled'.

Refs: #107
…nd configured

SessionState.checkpoint_enabled now defaults to True when SessionController
has a store (session persistence configured). ACP _ACPSessionProxy also
passes checkpoint_enabled based on store availability. This means durable
elicitation is automatically active for ACP and OpenCode servers when
storage is configured, without requiring explicit configuration.

Refs: #107
…s present

Without DeferredToolRequests in the agent's output_type, pydantic-ai
raises 'A deferred tool call was present, but DeferredToolRequests is
not among output types' when handle_elicitation() raises CallDeferred.
This was the root cause of the production error:
  ❌ Error: [engineer] A deferred tool call was present...

Refs: #107
Covers: DeferredToolRequests in output_type, checkpoint_enabled auto-set,
_ACPSessionProxy durable support, bridge handler with real DeferredToolRequests,
ACP event converter with real EventBus, handle_elicitation CallDeferred raise.

Refs: #107
…tation

The ACP event converter was emitting a ToolCallStart NOTIFICATION for
ElicitationDeferredEvent, but the ACP client expects an interactive
elicitation/create REQUEST (request-response, not fire-and-forget).

Now when _handle_event receives ElicitationDeferredEvent:
1. Spawns a background task (non-blocking for event consumer loop)
2. Sends elicitation/create request to ACP client via ACPRequests
3. Waits for user response
4. Builds ElicitationResumePayload from response
5. Calls session_pool.resume_session() with the payload

This closes the loop: handle_elicitation() → CallDeferred → bridge
checkpoint → ElicitationDeferredEvent → ACP elicitation/create → user
responds → resume_session → future resolved (in-process) or crash
recovery.

Refs: #107
The in-process resume path was fundamentally broken: handle_elicitation()
raises CallDeferred, which ends the agent run with DeferredToolRequests
as output. Nobody awaits the ElicitationFutureRegistry futures, so
resolving them does nothing — the agent run doesn't continue and the
stream is dead.

Fix: always fall through to crash recovery path (_resume_native_agent),
which re-executes the agent with cached_elicitation_responses. This
correctly re-runs the tool, handle_elicitation() returns the cached
response, the tool completes, and the agent produces events normally.

The in-process futures are still resolved (for registry cleanup), but
the early return is removed. The has_in_process_elicitation bypass of
SessionBusyError is kept (old run is completed but still registered).

Refs: #107
…tive_run=True

The elicitation bridge checkpoint saves checkpoint data but doesn't
update the session store status from 'active' to 'checkpointed'.
When resume_session() is called with allow_active_run=True (in-process
elicitation resume), the status check was still rejecting 'active'
status, causing SessionBusyError.

Now when allow_active_run=True, both 'checkpointed' and 'active'
statuses are allowed in the persisted session data check.

Refs: #107
…llDeferred

Major design correction: handle_elicitation() now has three paths:

1. Crash recovery: returns cached response (unchanged)
2. MCP tools (in_mcp_callback=True): raises CallDeferred (unchanged —
   FastMCP callbacks can't await for long periods)
3. Local tools (in_mcp_callback=False): checkpoints, emits event,
   registers future, and **awaits the future** — agent run suspends
   without ending. When user responds, future resolves, tool completes
   naturally, agent run continues. No re-execution.

Changes:
- context.py: Added checkpoint_manager to AgentRunContext, in_mcp_callback
  to AgentContext, rewrote handle_elicitation() with three-path logic
- agent.py: Store checkpoint_mgr on run_ctx.checkpoint_manager
- client.py: Set agent_ctx.in_mcp_callback=True before MCP call, clear
  in finally block
- core.py: resume_session() now returns early when in-process futures
  are resolved (agent run continues naturally, no crash recovery needed)
- Tests: Split test 9.2 into MCP path (raises CallDeferred) and local
  path (awaits future), updated protocol integration test

Refs: #107
@Leoyzen
Leoyzen force-pushed the feature/durable-elicitation-bridge branch from 5d58755 to bcc6e2a Compare July 6, 2026 03:02
…ssion data

SessionData.pending_deferred_calls is not updated when local tools
suspend on await future (only checkpoint storage is updated). The
in-process detection now checks the ElicitationFutureRegistry directly
instead of relying on session data. Also added __len__ to
ElicitationFutureRegistry for the non-empty check.

Fixes: SessionBusyError when resuming after local tool elicitation

Refs: #107
@Leoyzen
Leoyzen force-pushed the feature/durable-elicitation-bridge branch from bcc6e2a to 5481d56 Compare July 6, 2026 03:04
Leoyzen added 2 commits July 6, 2026 11:17
… resume

Two-level test covering the full in-process elicitation resume lifecycle:
- Level 1: Direct NativeTurn.execute() — core turn generator
- Level 2: RunHandle.start() → EventBus → consumer — integration layer

Both levels currently pass, confirming the core agent run and RunHandle
integration correctly yield StreamCompleteEvent after future resolution.
If the bug reappears, these tests will pinpoint which layer fails.

Refs: #107
…efaulting to zeros

NativeTurn.execute() was constructing ChatMessage without setting the usage
field, causing it to default to RequestUsage() (all zeros). The ACP event
converter reads message.usage (not message.cost_info) for UsageUpdate
notifications, so clients always saw 0 tokens.

Fix: extract RequestUsage from the last ModelResponse in new_messages and
pass it to ChatMessage constructor.

Refs: #107
@Leoyzen

Leoyzen commented Jul 6, 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 durable elicitation, enabling agent sessions to survive process crashes during user interaction by checkpointing state and deferring runs. It introduces a two-level interception mechanism for MCP tools to bypass FastMCP exception-catching, supports in-process and crash recovery resume paths, and integrates elicitation events into the ACP and OpenCode servers. The review feedback highlights several critical issues: the need to clean up registered futures on timeout or cancellation to avoid registration conflicts, raising a SessionBusyError if in-process resolution fails while a run is active to prevent concurrent executions, properly re-raising asyncio.CancelledError in background tasks, adding guard checks for null contexts, and actually filtering elicitation calls from deferred tool results as indicated by the code comments.

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/context.py Outdated
Comment thread src/agentpool/orchestrator/core.py Outdated
Comment thread src/agentpool_server/acp_server/handler.py Outdated
Comment thread src/agentpool/mcp_server/client.py
Comment thread src/agentpool/orchestrator/core.py Outdated
Leoyzen added 2 commits July 6, 2026 17:24
1. context.py: Add finally block to remove future from registry on
   timeout/cancellation, preventing ValueError on retry.
2. core.py: Guard against concurrent run if in-process elicitation
   resolution fails — raise SessionBusyError if run is still active.
3. handler.py: Re-raise CancelledError instead of swallowing it.
   User cancel comes as response.action='cancel', not CancelledError.
4. core.py: Fix misleading comment — no filtering needed because
   API contract separates elicitation payloads from deferred results.

Refs: #107
@Leoyzen
Leoyzen requested a review from Million-mo July 6, 2026 09:26
Replace ACP AgentMessageChunk ("🔄 Session resumed...") with a
backend-only log. The frontend doesn't need this notification —
streaming events from the resuming agent run are sufficient.

Refs: #107
@Leoyzen Leoyzen changed the title feat: Durable Elicitation Bridge — checkpoint/resume for MCP elicitation feat: Durable Elicitation Bridge — checkpoint/resume for elicitation requests Jul 6, 2026
@Leoyzen
Leoyzen marked this pull request as ready for review July 6, 2026 09:36
@Million-mo

Copy link
Copy Markdown
Collaborator

方案评估

整体方案 7/10 — 方向正确,三路分发设计优雅,本地工具零改造成本是亮点。但实现经历了多次方向修正(29 commits),暴露了几个值得关注的设计风险。

亮点

  1. 三路分发设计干净handle_elicitation() 的 crash recovery 缓存 → MCP 工具抛 CallDeferred → 本地工具 await future,让本地工具(如 question_for_user)零改造成本获得 durability。
  2. MCP 与本地工具区分得当:FastMCP 回调包装器会吞异常,所以 MCP 走 checkpoint → crash recovery,本地工具直接 await future 挂起 agent run,零重复执行。
  3. 策略抽象预留扩展空间ElicitationResolutionStrategy + ProtocolResolutionStrategy(MRTR 占位)为未来协议级 elicitation 预留接口。
  4. 向后兼容性好:所有新字段 optional,supports_durable_elicitation 默认 False,storage 可用时自动启用。
  5. 测试覆盖全面:42 个 elicitation 测试 + 460 个 orchestrator 测试。

设计风险

1. 本地工具 checkpoint 的 message_history=[] 问题 ⚠️

Path 3(本地工具)中 checkpoint() 传入 message_history=[],注释说 "bridge fills real history",但本地工具路径不走 bridgehandle_elicitation() 直接做了 checkpoint。如果本地工具触发 elicitation 后进程崩溃,crash recovery 时 message history 是空的,agent 会从头重新执行整个 turn 的所有工具调用。如果前面的工具有副作用(bash 命令、文件写入),会造成重复执行

MCP 路径不存在这个问题,因为 bridge capability 会正确填充 message history。

2. checkpoint 失败被静默吞掉

Path 3 中 checkpoint 包裹在 try/except Exception 里,失败后只 log 但继续执行 — agent 仍然 register future 并 await,用户以为 session 是 durable 的,但实际崩溃后无法恢复。应该 fail-fast 或通过事件明确告知前端 "此交互不可恢复"。

3. 300 秒硬编码超时

asyncio.wait_for(future, timeout=300) — 5 分钟对复杂表单可能不够,对快速 yes/no 又太长。超时后抛 RunAbortedError 直接终止 agent 运行,没有"继续等待"的选项。建议可配置化,并在 ElicitationDeferredEvent 中携带超时提示让前端展示倒计时。

4. ACP elicitation handler 的 fire-and-forget 风险

handler.py_handle_elicitation_deferred 作为 background task 运行,但如果 ACP 客户端断开连接,task 会挂在 elicitation_create() 上。_elicitation_tasks 集合在 _after_consumer_loopclose_session 中没有被清理。建议在 session 关闭时 cancel 这些 tasks。

5. Session 状态的 "liminal" 问题

allow_active_run=True 绕过状态检查接受 "active" 状态的 session 进行 resume,因为 elicitation bridge 保存了 checkpoint 数据但没把状态改为 "checkpointed"。其他检查 session 状态的代码(如 receive_request() 判断是否 idle)可能误判。建议引入 "elicitation_pending" 状态替代 workaround。

6. MCP 路径 crash recovery 重新执行整个 turn

_resume_native_agent() 重新创建 agent 并调用 agent.run_stream(),虽然 message history 中有之前的工具结果(pydantic-ai 不会重复调用已有结果的工具),但 system prompt 重新构建、model 重新调用,消耗额外 token 和延迟。这是 checkpoint/resume 架构的固有代价,不是本 PR 特有问题。

实现质量观察

从 commit 历史可以看到至少 4 次重大修正(side-channel → 直接 raise、in-process resume broken → always crash recovery → 又改回支持 in-process、session status 检查不通过 → 加 allow_active_run)。这些修正说明 handle_elicitationresume_sessionSessionController 之间的交互比预想的复杂,最终设计是合理的,但可能还有未发现的边界场景。

Gemini Code Assist 发现的两个关键问题(loop 内 checkpoint 覆盖、SessionBusyError 阻塞 in-process resume)被 bot review 而非自测发现,说明并发和边界场景的测试覆盖还有提升空间。

建议

  1. 修复 message_history=[]:本地工具 checkpoint 时传入实际 message history,否则 crash recovery 有重复执行风险
  2. checkpoint 失败不应静默:fail-fast 或通过事件告知前端
  3. 超时可配置化:放在 session/agent 配置里
  4. 清理 elicitation background tasks:在 close_session_after_consumer_loop 中 cancel 未完成的 tasks
  5. 考虑引入 elicitation_pending session 状态:替代 allow_active_run workaround

Leoyzen and others added 9 commits July 6, 2026 19:16
Re-applied elicitation bridge changes to new module structure:
- session_controller.py: SessionState.checkpoint_enabled, SessionClosedError
- session_pool.py: resume_session with elicitation_payloads, _try_in_process_elicitation_resume, _resume_native_agent with cached_elicitation, close_session with reject_all, _with_resume_lock allow_active_run
- event_bus.py: ElicitationDeferredEvent in _is_immediate
- core.py: re-export SessionClosedError
- Tests: fix EventBus API (Queue.get instead of receive_stream.receive)

Refs: #107
Add  field to BaseAgentConfig (defaults to 300s).
Supports string (5m, 300s), int (seconds), timedelta, or null
(infinite wait). The value flows through AgentRunContext to
handle_elicitation(), replacing the hardcoded 300s.

Also add  to ElicitationDeferredEvent so frontends
can display countdown timers.

YAML example:
  agents:
    my_agent:
      elicitation_timeout: 600s  # or null for infinite

Refs: #107
P0: Pass real message_history to checkpoint in handle_elicitation()
- Add current_messages field to AgentRunContext
- Set it in tool_wrapping.py from ctx.messages before each tool call
- Use run_ctx.current_messages instead of [] in checkpoint call
- Fixes crash recovery re-executing all prior tool calls (duplicate
  side effects)

P1a: Checkpoint failure no longer silently swallowed
- Use logger.warning with explicit message about degraded durability
- Don't set run_ctx.checkpointed=True on failure

P1b: Cancel ACP elicitation tasks on session close
- Cancel pending _elicitation_tasks in _after_consumer_loop
- Cancel and clear tasks in close_session

P2: Update session store status to 'checkpointed' after elicitation
bridge checkpoint
- Both handle_elicitation() (local tools) and elicitation_bridge.py
  (MCP tools) now update session store status from 'active' to
  'checkpointed' after saving checkpoint
- Eliminates the 'liminal' status that required allow_active_run
  workaround in _with_resume_lock
- Wrapped in try/except so failures don't break the checkpoint path

Refs: #107
- P0: verify handle_elicitation passes current_messages to checkpoint
  (not empty list) — prevents crash recovery re-executing tools
- P1a: verify checkpoint failure doesn't set checkpointed=True — the
  in-process future await still works but crash recovery is degraded
- P2: verify session store status is updated to 'checkpointed' after
  elicitation checkpoint, and skipped if already checkpointed

Refs: #107
… violation

agentpool_config.nodes was importing agentpool.utils.parse_time,
breaking the 'Config must not import from core' contract.

Inlined a simple regex-based parser in the field_validator to
maintain layer separation.

Refs: #107
…gentlet()

from_config() pre-built capabilities and stored them in _extra_capabilities,
while get_agentlet() also iterated self.config.capabilities and built them
again — producing duplicate FunctionToolset instances with conflicting tool
names (e.g. two 'task' tools from BackgroundTaskCapability).

Fix: skip pre-building in from_config(); let get_agentlet() handle all
capability construction lazily from self.config.capabilities.
AbstractCapability base class uses KW_ONLY for its fields (id,
description, defer_loading), all with defaults. The subclass field
hook_manager (no default) was not keyword-only, causing a dataclass
TypeError when running against pydantic-ai v1.x where the base class
is @DataClass(init=False).
cancel_session() only cancelled the parent session's RunHandle, leaving child (subagent) sessions running independently. Added _cancel_subagent_runs() which recursively walks the _parent_of tree and calls cancel_run_for_session() for each child before cancelling the parent.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…rable-elicitation-bridge

# Conflicts:
#	tests/agents/native_agent/test_get_agentlet_capabilities.py
#	tests/servers/acp_server/test_acp_protocol_handler_cancel.py
@Leoyzen
Leoyzen merged commit 2fc8a23 into develop/agentic Jul 7, 2026
9 checks passed
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.

Bridge MCP Elicitation to Deferred/Durable Execution Pipeline

2 participants