Skip to content

AG-UI adapter drops human-in-the-loop tool confirmation (consumes deprecated v1 Event stream; RequireUserConfirmEvent never bridged) #2437

Description

@wzq-xzwj

Summary

The AG-UI adapter (AguiAgentAdapter) consumes the deprecated v1 Flux<io.agentscope.core.agent.Event> stream via agent.stream(...). Because the v1 Event/EventType vocabulary only contains REASONING / SUMMARY / TOOL_RESULT, the fine-grained v2 events — in particular RequireUserConfirmEvent and RequestStopEventstructurally can never reach the adapter.

As a result, human-in-the-loop (HITL) tool confirmation is completely dropped over AG-UI:

  • When a tool call enters ToolCallState.ASKING (e.g. any non-read-only tool under PermissionMode.DEFAULT, which is the default), ReActAgent emits RequireUserConfirmEvent + RequestStopEvent and pauses.
  • Neither event is converted to any AG-UI event, so the client sees TOOL_CALL_START/ARGS/END and then a bare RUN_FINISHED (or a RUN_ERROR on a subsequent run whose paused state was persisted) — the tool call silently never executes.
  • There is also no inbound path: AguiRequestProcessor / RunAgentInput / AguiMessageConverter never read confirmation results back, so even a client that wanted to approve/deny has no channel to send ConfirmResult (Msg metadata key agentscope_confirm_results).

This is especially visible with MCP tools: the MCP server receives initialize and tools/list but never receives tools/call, because the agent halts at the ASKING gate before ToolExecutor runs.

Environment

  • agentscope-java: 2.0.0
  • Module: agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui
  • Transport: AG-UI over SSE (agentscope-agui-spring-boot-starter, MVC SseEmitter)

Root cause (verified against main)

1. The adapter is on the deprecated v1 event stream.

AguiAgentAdapter.run(RunAgentInput) calls:

// AguiAgentAdapter.java
Flux<Event> agentEvents = agent.stream(msgs, options, runtimeContext); // io.agentscope.core.agent.Event (v1)
...
agentEvents.concatMapIterable(event -> convertEvent(event, state));

and convertEvent(Event, ...) only branches on EventType.REASONING | SUMMARY | TOOL_RESULT. The entire StreamableAgent surface is already marked for removal:

// StreamableAgent.java
 * @deprecated since 2.0.0, for removal in a future minor release. Every stream(...)
 *     method returns the coarse-grained Event type (a v1 carry-over). Use
 *     ReActAgent#streamEvents(...) insteadit returns Flux<io.agentscope.core.event.AgentEvent>,
 *     the fine-grained event hierarchy ...
@Deprecated(since = "2.0.0", forRemoval = true)
public interface StreamableAgent { ... }

The v1 Event.java deprecation javadoc even names AG-UI explicitly as a module still on the old wire format:

 * @deprecated since 2.0.0. This is the v1 coarse-grained event type ... the wire format
 *     consumed by the harness, AGUI, A2A, chat-completions-web, and Kotlin extension modules.
 *     New code should use io.agentscope.core.event.AgentEvent via ReActAgent#streamEvents(...).

2. The two event hierarchies are disjoint and there is no bridge.

io.agentscope.core.agent.Event (v1, a flat class wrapping Msg + coarse EventType) and io.agentscope.core.event.AgentEvent (v2 abstract base with 28 typed subtypes and its own AgentEventType) are in different packages, unrelated by inheritance. There is no in-core converter from AgentEvent down to v1 Event. So RequireUserConfirmEvent cannot be observed from the stream the adapter currently subscribes to — a "small patch inside the v1 adapter" is not possible.

3. The AG-UI layer already defines the target types but never constructs them.

AguiEvent.java defines Interrupt, RunFinishedOutcome, RunFinishedSuccessOutcome, and RunFinishedInterruptOutcome(List<Interrupt>) — the Interrupt javadoc reads "requiring user input to resolve before the agent can continue" — but no production code ever instantiates them. finishRun(...) always emits the bare new AguiEvent.RunFinished(threadId, runId) (outcome = null). The interrupt types are exercised only in AguiEventTest (serialization).

Reproduction

  1. Register any HarnessAgent/ReActAgent with default permissions (PermissionMode.DEFAULT) behind the AG-UI Spring Boot starter.
  2. Configure any non-read-only tool (e.g. an MCP echo tool via tools.json).
  3. POST /agui/run asking the model to call that tool.

Observed: SSE stream shows RUN_STARTED → TEXT_* → TOOL_CALL_START/ARGS/END → RUN_FINISHED, with no TOOL_CALL_RESULT and no confirmation event. Server-side, the agent stops at PRE_ACTING with RequestStopEvent(reason=permission asking) and ToolExecutor never runs; an MCP server never receives tools/call.

Expected: the confirmation request should surface to the client (as an AG-UI Interrupt / RunFinishedInterruptOutcome, or another agreed representation), and the client should be able to resume the run by sending approval/denial that is translated back into ConfirmResult (Msg metadata agentscope_confirm_results).

Workaround today: set PermissionMode.BYPASS (or add explicit allow rules) on the agent's PermissionContextState, which skips the ASK gate entirely. This unblocks tool execution but discards the HITL capability that AG-UI otherwise seems designed to support.

Reference for the intended v2 pattern

The canonical dispatch + resume flow already exists in tests:

  • agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentHitlTest.java — subscribes to streamEvents(...), dispatches via instanceof over RequireUserConfirmEvent / RequestStopEvent (GenerateReason.PERMISSION_ASKING), extracts pending ToolUseBlocks via getToolCalls(), and resumes with new ConfirmResult(approved, toolCall).
  • agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentSubagentStreamEventsTest.java — subscribes to streamEvents(...) and dispatches by AgentEventType.

Suggested direction

Migrate AguiAgentAdapter from the deprecated agent.stream(...) (Flux<Event>) to ReActAgent#streamEvents(...) (Flux<AgentEvent>), rewrite convertEvent to dispatch over the typed v2 events (TEXT_BLOCK_*, THINKING_BLOCK_*, TOOL_CALL_*, TOOL_RESULT_*), and add HITL handling that maps RequireUserConfirmEventAguiEvent.Interrupt (+ RunFinishedInterruptOutcome) outbound, plus an inbound path that reads confirmation results from the incoming RunAgentInput and forwards them as ConfirmResult (Msg.METADATA_CONFIRM_RESULTS).

Note: streamEvents is declared on ReActAgent, not on the Agent interface, so the adapter would need to target ReActAgent (or a narrower event-streaming type) — worth deciding as part of the fix.

I'm happy to work on a PR for this.


Note: HITL confirmation over AG-UI is currently non-functional; the adapter being on the forRemoval v1 stream is the underlying cause. This is a defensive/UX correctness issue rather than a data-loss bug.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions