You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Bug]:AG-UI: tool results from a resumed run are dropped because the stream context is per-run #3096
Permission-mode HITL spans two runs. Run 1 emits TOOL_CALL_START/ARGS/END and finishes with an
interrupt. After the user approves, run 2 executes the tool and produces ToolResultStartEvent →
delta events → ToolResultEndEvent.
Every AG-UI event from run 2 for that tool call is silently discarded, so the client never receives TOOL_CALL_RESULT and the tool card stays unresolved forever.
Why
AguiAgentAdapter#streamWithRuntimeContext creates a newAguiStreamContext per run (both the
ReActAgent and HarnessAgent branches). Nothing carries over from the previous run.
ReActAgent#resumeAgent() is return acting(0) — it does not replay ToolCallStart/Delta/End for
the tool call that was paused, so the new context never records the toolCallId.
AguiStreamContext#beginToolResult / appendToolResultText / appendToolResultData / endToolResult all guard on the private startedToolCalls set via hasStartedToolCall(...).
The id is absent, so the whole result is dropped.
There is no public API to query or seed startedToolCalls, so an application-side converter cannot
tell "this is a cross-run result" from "this is a bogus id" either.
What the AG-UI spec expects
A resumed run should emit TOOL_CALL_RESULT for the original toolCallId without replaying TOOL_CALL_START/ARGS/END. The client correlates by toolCallId. (@ag-ui/client's verifyEvents
does not require a preceding TOOL_CALL_START for TOOL_CALL_RESULT, so the standard shape is
accepted by the official client.)
Current workaround
A custom AgentEventConverter overriding ToolResultStartEvent that makes the context adopt the id
silently: call startToolCall(id, name) + endToolCall(id), then beginEvent() to discard the TOOL_CALL_START/TOOL_CALL_END those two calls queued (the registry calls beginEvent() before and drainEvents() after each event, so only the current event's output is cleared), then beginToolResult(id). This produces the spec-compliant single TOOL_CALL_RESULT, but it leans on
internal ordering guarantees and is fragile across upgrades.
Suggested fix
Either
let a resumed run map tool results for tool calls belonging to a known resume interrupt (the
interrupt id already carries replyId:toolCallId), or
expose a public entry point on AguiStreamContext to adopt a tool call id without emitting events,
e.g. adoptToolCall(String toolCallId, String toolCallName).
Related: #2437 (interrupt not propagated, fixed by #2495), #2492, #2956. PR #1231 shows the previous
adapter could back-fill a missing tool-call; that capability was lost in the converter-based design.
I reproduced it on current main at commit c6af781836b23b1325968585104445856c57bba3 using the existing AguiAgentAdapterV2Test fixtures:
Baseline: 82 tests passed.
With a cross-run permission-resume regression test: 84 tests, 1 expected failure. Run 1 emits the tool call and permission interrupt; run 2 emits ToolResultStartEvent / deltas / ToolResultEndEvent for the same toolCallId. Expected TOOL_CALL_RESULT, but the output is only RUN_STARTED → RUN_FINISHED.
The control case with an unknown toolCallId still passes and is ignored.
I also checked related and unlinked open/closed/merged PRs, including #2890, #2955, #3013, #3046, #2922, #2908, #2874, #2473, #2495, and #2605. I did not find one that addresses this same output-conversion root cause.
My proposed scope is narrow: allow a resumed run to adopt only a tool-call ID backed by a validated resume interrupt, emit exactly one TOOL_CALL_RESULT without replaying TOOL_CALL_START/ARGS/END, and keep unknown IDs ignored.
Would this direction be acceptable, and is anyone already working on it?
Confirmed on 2.0.3 (agentscope-extensions-agui) with a scripted model — no network involved, so the repro is fully deterministic.
Run 1 emits TOOL_CALL_START / TOOL_CALL_ARGS / TOOL_CALL_END and finishes with RUN_FINISHED whose outcome.type = "interrupt" (interrupt id is replyId:toolCallId, metadata agentscope.interruptKind = permission_confirm). Run 2 — same threadId, resume: [{status: "resolved", payload: {approved: true}}] — does execute the tool and does put ToolResultBlock(state=SUCCESS) into the second model call, but the SSE stream for run 2 contains neither TOOL_CALL_END nor TOOL_CALL_RESULT. Exactly the mechanism described in the issue: beginToolResult / endToolResult gate on hasStartedToolCall(toolCallId), and AguiStreamContext is created per run, so the resumed run has no record of a tool call that started in the previous one. The model gets the result; only the event stream (and therefore any UI rendering it) does not.
Two things worth adding for anyone hitting this from the other direction:
If you reconstruct the pending ToolUseBlock yourself when resuming, note that tool dispatch validates ToolUseBlock.getContent() (the raw JSON args string), not getInput(). A block built with only the input map fails with Parameter validation failed for tool 'x': missing required property "y" and the tool never runs — which looks identical to "the approved run dropped the result" but is a different bug, in the caller.
editedArgs in the resume payload works as documented (whole replacement, and both the tool and the model's history then show the edited value), so the fix only needs to restore the event, not the argument plumbing.
Would be great to get a release containing #3100 — v2.0.3 is still the newest tag, so the workaround in the issue body is currently the only option, and it leans on internal beginEvent() / drainEvents() ordering.
Activity
Shay-Deng commentedon Sep 11, 2026
Hi, I'd like to work on this issue.
I reproduced it on current
mainat commitc6af781836b23b1325968585104445856c57bba3using the existingAguiAgentAdapterV2Testfixtures:ToolResultStartEvent/ deltas /ToolResultEndEventfor the sametoolCallId. ExpectedTOOL_CALL_RESULT, but the output is onlyRUN_STARTED → RUN_FINISHED.toolCallIdstill passes and is ignored.I also checked related and unlinked open/closed/merged PRs, including #2890, #2955, #3013, #3046, #2922, #2908, #2874, #2473, #2495, and #2605. I did not find one that addresses this same output-conversion root cause.
My proposed scope is narrow: allow a resumed run to adopt only a tool-call ID backed by a validated resume interrupt, emit exactly one
TOOL_CALL_RESULTwithout replayingTOOL_CALL_START/ARGS/END, and keep unknown IDs ignored.Would this direction be acceptable, and is anyone already working on it?
fix(agui): preserve tool results after permission resume (agentscope-…
hejian900625 commentedon Oct 1, 2026
Confirmed on 2.0.3 (
agentscope-extensions-agui) with a scripted model — no network involved, so the repro is fully deterministic.Run 1 emits
TOOL_CALL_START/TOOL_CALL_ARGS/TOOL_CALL_ENDand finishes withRUN_FINISHEDwhoseoutcome.type = "interrupt"(interrupt id isreplyId:toolCallId, metadataagentscope.interruptKind = permission_confirm). Run 2 — samethreadId,resume: [{status: "resolved", payload: {approved: true}}]— does execute the tool and does putToolResultBlock(state=SUCCESS)into the second model call, but the SSE stream for run 2 contains neitherTOOL_CALL_ENDnorTOOL_CALL_RESULT. Exactly the mechanism described in the issue:beginToolResult/endToolResultgate onhasStartedToolCall(toolCallId), andAguiStreamContextis created per run, so the resumed run has no record of a tool call that started in the previous one. The model gets the result; only the event stream (and therefore any UI rendering it) does not.Two things worth adding for anyone hitting this from the other direction:
ToolUseBlockyourself when resuming, note that tool dispatch validatesToolUseBlock.getContent()(the raw JSON args string), notgetInput(). A block built with only the input map fails withParameter validation failed for tool 'x': missing required property "y"and the tool never runs — which looks identical to "the approved run dropped the result" but is a different bug, in the caller.editedArgsin the resume payload works as documented (whole replacement, and both the tool and the model's history then show the edited value), so the fix only needs to restore the event, not the argument plumbing.Would be great to get a release containing #3100 —
v2.0.3is still the newest tag, so the workaround in the issue body is currently the only option, and it leans on internalbeginEvent()/drainEvents()ordering.