-
Notifications
You must be signed in to change notification settings - Fork 484
feat(acp): stream ACPToolCallEvents live from session_update #2868
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+682
−59
Merged
Changes from 4 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
ea808bd
feat(acp): stream ACPToolCallEvents live from session_update
fb60def
review: extract reset helper, tighten on_event type, apply ruff format
282b878
review: close pending ACP tool cards on retry / abort
94c5e0e
review: tighten cancel helper, cite state-lock in concurrency docs
9740e56
review: unwire per-turn callbacks after step() to close late-update race
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -109,6 +109,12 @@ | |
| # well below the ~20 min runtime-api kill threshold. | ||
| _ACTIVITY_SIGNAL_INTERVAL: float = 30.0 | ||
|
|
||
| # ACP tool-call statuses that represent a terminal outcome. Non-terminal | ||
| # statuses (``pending``, ``in_progress``) mean the call is still in flight | ||
| # and, if the turn aborts before it reaches a terminal state, the live- | ||
| # emitted event on state.events will otherwise be orphaned forever. | ||
| _TERMINAL_TOOL_CALL_STATUSES: frozenset[str] = frozenset({"completed", "failed"}) | ||
|
|
||
|
|
||
| def _make_dummy_llm() -> LLM: | ||
| """Create a dummy LLM that should never be called directly.""" | ||
|
|
@@ -286,13 +292,43 @@ class _OpenHandsACPBridge: | |
| """Bridge between OpenHands and ACP that accumulates session updates. | ||
|
|
||
| Implements the ``Client`` protocol from ``agent_client_protocol``. | ||
|
|
||
| Concurrency model — ``on_event`` / ``on_token`` / ``on_activity`` are | ||
| fired synchronously from ``session_update``, which runs on the | ||
| ``AsyncExecutor`` portal thread. The guarantees that keep callbacks | ||
| serialized within a single turn rely on the combination of two things, | ||
| not the GIL alone: | ||
|
|
||
| 1. ``LocalConversation.run()`` calls ``agent.step(...)`` while holding | ||
| the reentrant ``ConversationState`` lock (a ``FIFOLock``) — see | ||
| ``local_conversation.py`` where ``self.agent.step(...)`` sits inside | ||
| ``with self._state:``. The caller thread owns that lock for the | ||
| entire duration of ``step()``, so no other thread can append to | ||
| ``state.events`` during the turn. | ||
| 2. ``portal.call(_prompt)`` blocks the caller thread until ``prompt()`` | ||
| returns. Live ``on_event`` calls happen on the portal thread while | ||
| the caller thread is parked inside ``portal.call()`` still owning | ||
| the state lock; the final ``MessageEvent`` / ``FinishAction`` run | ||
| on the caller thread after ``prompt()`` returns. The two phases | ||
| never overlap in time. | ||
|
|
||
| The caller's state-lock ownership is what excludes *other* threads | ||
| (hook workers, remote-conversation push layers, visualizers spawned | ||
| elsewhere) from racing with either phase. The ordering between the | ||
| two phases is what keeps a single consumer's cross-callback state | ||
| (e.g. hook processors that read-then-write) consistent. | ||
| """ | ||
|
|
||
| def __init__(self) -> None: | ||
| self.accumulated_text: list[str] = [] | ||
| self.accumulated_thoughts: list[str] = [] | ||
| self.accumulated_tool_calls: list[dict[str, Any]] = [] | ||
| self.on_token: Any = None # ConversationTokenCallbackType | None | ||
| # Live event sink — fired from session_update as ACP tool-call | ||
|
simonrosenberg marked this conversation as resolved.
simonrosenberg marked this conversation as resolved.
|
||
| # updates arrive, so the event stream reflects real subprocess | ||
| # progress instead of a single end-of-turn burst. Set by | ||
| # ACPAgent.step() for the duration of one prompt() round-trip. | ||
| self.on_event: ConversationCallbackType | None = None | ||
| # Activity heartbeat — called (throttled) during session_update to | ||
| # signal that the ACP subprocess is still actively working. Set by | ||
| # ACPAgent.step() to keep the agent-server's idle timer alive. | ||
|
|
@@ -317,6 +353,7 @@ def reset(self) -> None: | |
| self.accumulated_thoughts.clear() | ||
| self.accumulated_tool_calls.clear() | ||
| self.on_token = None | ||
| self.on_event = None | ||
| self.on_activity = None | ||
| self._turn_usage_updates.clear() | ||
| self._usage_received.clear() | ||
|
|
@@ -378,21 +415,22 @@ async def session_update( | |
| if event is not None: | ||
| event.set() | ||
| elif isinstance(update, ToolCallStart): | ||
| self.accumulated_tool_calls.append( | ||
| { | ||
| "tool_call_id": update.tool_call_id, | ||
| "title": update.title, | ||
| "tool_kind": update.kind, | ||
| "status": update.status, | ||
| "raw_input": update.raw_input, | ||
| "raw_output": update.raw_output, | ||
| "content": _serialize_tool_content(update.content), | ||
| } | ||
| ) | ||
| entry = { | ||
| "tool_call_id": update.tool_call_id, | ||
| "title": update.title, | ||
| "tool_kind": update.kind, | ||
| "status": update.status, | ||
| "raw_input": update.raw_input, | ||
| "raw_output": update.raw_output, | ||
| "content": _serialize_tool_content(update.content), | ||
| } | ||
| self.accumulated_tool_calls.append(entry) | ||
| logger.debug("ACP tool call start: %s", update.tool_call_id) | ||
| self._emit_tool_call_event(entry) | ||
| self._maybe_signal_activity() | ||
| elif isinstance(update, ToolCallProgress): | ||
| # Find the existing tool call entry and merge updates | ||
| target: dict[str, Any] | None = None | ||
| for tc in self.accumulated_tool_calls: | ||
| if tc["tool_call_id"] == update.tool_call_id: | ||
| if update.title is not None: | ||
|
|
@@ -407,12 +445,41 @@ async def session_update( | |
| tc["raw_output"] = update.raw_output | ||
| if update.content is not None: | ||
| tc["content"] = _serialize_tool_content(update.content) | ||
| target = tc | ||
| break | ||
| logger.debug("ACP tool call progress: %s", update.tool_call_id) | ||
| if target is not None: | ||
| self._emit_tool_call_event(target) | ||
| self._maybe_signal_activity() | ||
| else: | ||
| logger.debug("ACP session update: %s", type(update).__name__) | ||
|
|
||
| def _emit_tool_call_event(self, tc: dict[str, Any]) -> None: | ||
|
simonrosenberg marked this conversation as resolved.
|
||
| """Emit an ACPToolCallEvent reflecting the current state of ``tc``. | ||
|
|
||
| Called from ``session_update`` on each ``ToolCallStart`` / | ||
| ``ToolCallProgress`` so downstream consumers see tool cards appear | ||
| and update as the subprocess runs. The same ``tool_call_id`` is | ||
| reused on every emission — consumers should dedupe by id and treat | ||
| the last-seen event as authoritative. | ||
| """ | ||
| if self.on_event is None: | ||
| return | ||
| try: | ||
| event = ACPToolCallEvent( | ||
| tool_call_id=tc["tool_call_id"], | ||
| title=tc["title"], | ||
| status=tc.get("status"), | ||
| tool_kind=tc.get("tool_kind"), | ||
| raw_input=tc.get("raw_input"), | ||
| raw_output=tc.get("raw_output"), | ||
| content=tc.get("content"), | ||
| is_error=tc.get("status") == "failed", | ||
| ) | ||
| self.on_event(event) | ||
|
simonrosenberg marked this conversation as resolved.
|
||
| except Exception: | ||
|
simonrosenberg marked this conversation as resolved.
|
||
| logger.debug("on_event callback failed", exc_info=True) | ||
|
|
||
| def _maybe_signal_activity(self) -> None: | ||
| """Signal activity to the agent-server's idle tracker (throttled). | ||
|
|
||
|
|
@@ -883,6 +950,70 @@ async def _init() -> tuple[Any, Any, Any, str, str, str]: | |
| ) = result | ||
| self._working_dir = working_dir | ||
|
|
||
| def _reset_client_for_turn( | ||
|
simonrosenberg marked this conversation as resolved.
|
||
| self, | ||
| on_token: ConversationTokenCallbackType | None, | ||
| on_event: ConversationCallbackType, | ||
| ) -> None: | ||
| """Reset per-turn client state and (re)wire live callbacks. | ||
|
simonrosenberg marked this conversation as resolved.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Good: The |
||
|
|
||
| Called at the start of ``step()`` and again on each retry inside the | ||
| prompt loop so that the three callbacks (``on_token``, ``on_event``, | ||
| ``on_activity``) stay in sync with the fresh turn after ``reset()`` | ||
| clears them. ``on_event`` is fired from inside | ||
| ``_OpenHandsACPBridge.session_update`` as tool-call notifications | ||
| arrive, so consumers see ACPToolCallEvents streamed live instead of | ||
| a single end-of-turn burst. | ||
| """ | ||
| self._client.reset() | ||
| self._client.on_token = on_token | ||
| self._client.on_event = on_event | ||
| self._client.on_activity = self._on_activity | ||
|
|
||
| def _cancel_inflight_tool_calls(self) -> None: | ||
| """Emit a terminal ``failed`` ACPToolCallEvent for every tool call | ||
| in the accumulator that has not reached a terminal status yet. | ||
|
|
||
| ACP servers mint fresh ``tool_call_id``s on a retried turn, so any | ||
| ``pending`` / ``in_progress`` events already streamed during the | ||
| failed attempt would otherwise be orphaned on ``state.events`` — | ||
| no later notification reuses their id, and consumers that dedupe | ||
| by ``tool_call_id`` + "last-seen status wins" would keep them | ||
| spinning forever. This method closes those cards before we wipe | ||
| the in-memory accumulator on retry / turn abort. | ||
|
simonrosenberg marked this conversation as resolved.
|
||
|
|
||
| Uses the bridge's ``on_event`` directly (the same callback driving | ||
| live emissions); call this *before* ``_reset_client_for_turn`` so | ||
| the callback is still wired up. No-op if ``on_event`` was never | ||
| set (e.g. during tests exercising the bridge in isolation). | ||
| """ | ||
| on_event = self._client.on_event | ||
| if on_event is None: | ||
| return | ||
| for tc in self._client.accumulated_tool_calls: | ||
| status = tc.get("status") | ||
| if status in _TERMINAL_TOOL_CALL_STATUSES: | ||
| continue | ||
| try: | ||
| on_event( | ||
| ACPToolCallEvent( | ||
| tool_call_id=tc["tool_call_id"], | ||
| title=tc["title"], | ||
| status="failed", | ||
| tool_kind=tc.get("tool_kind"), | ||
| raw_input=tc.get("raw_input"), | ||
| raw_output=tc.get("raw_output"), | ||
| content=tc.get("content"), | ||
| is_error=True, | ||
| ) | ||
| ) | ||
| except Exception: | ||
| logger.debug( | ||
| "Failed to emit supersede event for %s", | ||
| tc.get("tool_call_id"), | ||
| exc_info=True, | ||
| ) | ||
|
|
||
| @observe(name="acp_agent.step", ignore_inputs=["conversation", "on_event"]) | ||
| def step( | ||
| self, | ||
|
|
@@ -910,10 +1041,7 @@ def step( | |
| state.execution_status = ConversationExecutionStatus.FINISHED | ||
| return | ||
|
|
||
| # Reset client accumulators | ||
| self._client.reset() | ||
| self._client.on_token = on_token | ||
| self._client.on_activity = self._on_activity | ||
| self._reset_client_for_turn(on_token, on_event) | ||
|
|
||
| t0 = time.monotonic() | ||
| try: | ||
|
|
@@ -971,8 +1099,8 @@ async def _prompt() -> PromptResponse: | |
| e, | ||
| ) | ||
| time.sleep(delay) | ||
| self._client.reset() | ||
| self._client.on_token = on_token | ||
| self._cancel_inflight_tool_calls() | ||
| self._reset_client_for_turn(on_token, on_event) | ||
| else: | ||
| raise | ||
| except ACPRequestError as e: | ||
|
|
@@ -996,8 +1124,8 @@ async def _prompt() -> PromptResponse: | |
| e, | ||
| ) | ||
| time.sleep(delay) | ||
| self._client.reset() | ||
| self._client.on_token = on_token | ||
| self._cancel_inflight_tool_calls() | ||
| self._reset_client_for_turn(on_token, on_event) | ||
| else: | ||
| raise | ||
|
|
||
|
|
@@ -1013,19 +1141,11 @@ async def _prompt() -> PromptResponse: | |
| usage_update=usage_update, | ||
| ) | ||
|
|
||
| # Emit ACPToolCallEvents for each accumulated tool call | ||
| for tc in self._client.accumulated_tool_calls: | ||
| tc_event = ACPToolCallEvent( | ||
| tool_call_id=tc["tool_call_id"], | ||
| title=tc["title"], | ||
| status=tc.get("status"), | ||
| tool_kind=tc.get("tool_kind"), | ||
| raw_input=tc.get("raw_input"), | ||
| raw_output=tc.get("raw_output"), | ||
| content=tc.get("content"), | ||
| is_error=tc.get("status") == "failed", | ||
| ) | ||
| on_event(tc_event) | ||
| # ACPToolCallEvents were already emitted live from | ||
| # _OpenHandsACPBridge.session_update as each ToolCallStart / | ||
| # ToolCallProgress notification arrived — no end-of-turn fan-out | ||
| # here. The final MessageEvent + FinishAction still close out | ||
| # the turn below. | ||
|
simonrosenberg marked this conversation as resolved.
|
||
|
|
||
| # Build response message | ||
| response_text = "".join(self._client.accumulated_text) | ||
|
|
@@ -1101,12 +1221,17 @@ async def _prompt() -> PromptResponse: | |
| ) | ||
| ], | ||
| ) | ||
| # Close any tool cards left in flight from the timed-out attempt. | ||
| self._cancel_inflight_tool_calls() | ||
| on_event(MessageEvent(source="agent", llm_message=error_message)) | ||
| state.execution_status = ConversationExecutionStatus.ERROR | ||
| except Exception as e: | ||
| logger.error("ACP prompt failed: %s", e, exc_info=True) | ||
| error_str = str(e) | ||
|
|
||
| # Close any tool cards left in flight before surfacing the error. | ||
| self._cancel_inflight_tool_calls() | ||
|
|
||
| # Emit error as an agent message (existing behavior, preserved for | ||
| # consumers that inspect MessageEvents) | ||
| error_message = Message( | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.