Skip to content

fix(a2a): map HITL confirmation events to A2A - #2577

Open
mikemikimike wants to merge 31 commits into
agentscope-ai:mainfrom
mikemikimike:issue-2552-stream-events
Open

mikemikimike wants to merge 31 commits into
agentscope-ai:mainfrom
mikemikimike:issue-2552-stream-events

Conversation

@mikemikimike

@mikemikimike mikemikimike commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Map RequireUserConfirmEvent to an A2A INPUT_REQUIRED task with the pending tool calls.
  • Retain the paused ReActAgent while awaiting confirmation, validate the reply against the pending task, and resume execution with the confirmed results.
  • Cover confirmation, resume, stale or mismatched replies, and agent lifecycle cleanup.

Addresses #2552

Closes #2552.

Tests

  • mvn -B -T1 -pl agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-a2a/agentscope-extensions-a2a-server -am clean verify (passed; 2,462 core tests, 111 A2A client tests, and 140 A2A server tests; 9 core tests skipped)

@CLAassistant

CLAassistant commented Aug 6, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@codecov

codecov Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.92593% with 78 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...e/a2a/server/executor/AgentScopeAgentExecutor.java 74.45% 28 Missing and 31 partials ⚠️
...a/server/executor/runner/BaseReActAgentRunner.java 78.16% 10 Missing and 9 partials ⚠️

📢 Thoughts on this report? Let us know!

@zcw888

zcw888 commented Aug 7, 2026

Copy link
Copy Markdown

PR #2577 added the AgentRunner.streamEvents() capability (fine-grained Flux<AgentEvent>) and implemented it in BaseReActAgentRunner, but the fix looks incomplete: the actual A2A server call chain still uses the legacy coarse-grained stream.

In AgentScopeAgentExecutor.execute() (agentscope-extensions-a2a-server), line 101 still calls agentRunner.stream(...) returning Flux<Event>, and the whole handler chain (BaseFluxEventHandler, BlockingFluxEventHandler, StreamingFluxEventHandler) consumes Event. AgentRunner.streamEvents() is only exercised by AgentRunnerTest and is never consumed by any executor, so A2A clients still cannot receive fine-grained AgentEvents — this is exactly what #2552 asks for.

To fully close this issue, AgentScopeAgentExecutor (and its FluxEventHandler implementations / tests) should be switched to consume streamEvents() / AgentEvent, ideally with a backward-compatible adapter so the existing Event consumers keep working. Could a follow-up PR cover the executor-side wiring?

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This PR modifies 3 files with 254 lines of diff.

Observations

  • Files changed: 3
  • Test coverage: ✅ Tests included
  • CLA: ✅ Signed

Suggestions

  • Please ensure all public API changes are backward compatible
  • Consider adding unit tests for new logic paths

Automated review by github-manager-bot

@mikemikimike

Copy link
Copy Markdown
Contributor Author

Addressed the executor-side wiring noted in the review comment:

  • AgentScopeAgentExecutor now prefers streamEvents() and routes fine-grained AgentEvent values through the blocking and streaming handlers.
  • Legacy custom runners remain supported through an adapter/fallback to stream().
  • Added regression coverage for fine-grained execution and legacy fallback.

Validation at 489f67d:

  • mvn -pl agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-a2a/agentscope-extensions-a2a-server -am clean verify ✅ (core: 2,257 tests; A2A client: 109; A2A server: 138; 8 existing core skips)
  • git diff --check ✅

The previous required Ubuntu run failed in the unrelated agentscope-harness module; the focused A2A reactor passed locally.

@AgentScopeJavaBot AgentScopeJavaBot added enhancement New feature or request area/ext/integration External protocols & middleware integrations labels Aug 11, 2026
@mikemikimike
mikemikimike marked this pull request as draft August 22, 2026 13:55
@mikemikimike
mikemikimike marked this pull request as ready for review August 22, 2026 14:48
@mikemikimike

Copy link
Copy Markdown
Contributor Author

I rechecked this branch against the current base commit cdf1e5a. Upstream commit d588528 (merged as PR #2222) already migrates AgentRunner, BaseReActAgentRunner, and AgentScopeAgentExecutor to the fine-grained AgentEvent/streamEvents API described by issue #2552. The extra compatibility layer in this PR reintroduces the deprecated stream() method as a required abstract method; because the previous AgentRunner contract only required streamEvents(), that change can break existing custom AgentRunner implementations. The A2A module clean verify passes locally, but the current branch is duplicating the upstream migration and its Codecov patch check is still red. Could a maintainer please review whether this PR is superseded by #2222 and should be closed or reduced to any remaining delta?

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Delta re-review after the 8 author commits since 59a23db9. The compatibility story is now much better: streamEvents gained a default, stream is back for legacy runners, BaseReActAgentRunner factors both through one streamWithAgent helper, and the executor wraps legacy Events into AgentEvents so the handler path stays single-model. Cache/error coverage is solid (AgentRunnerTest +5 cases, AgentScopeAgentExecutorTest +3). What is left is about how the fallback is triggered and when state is registered/suppressed — three inline points below.

Verdict

COMMENT — the sentinel-vs-real-error ambiguity (AgentRunner.java:74) is the one I would like resolved or explicitly accepted; the assembly-time cache registration (BaseReActAgentRunner.java:69) is the riskier of the two mechanically. CLA signed.

* @return Flux of fine-grained events emitted during execution, or a Flux that terminates with
* {@link UnsupportedOperationException} when the runner does not support fine-grained events
*/
default Flux<AgentEvent> streamEvents(List<Msg> requestMessages, AgentRequestOptions options) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UnsupportedOperationException is doing double duty here: it is the capability sentinel for not-yet-migrated runners and simultaneously a plausible genuine failure from a migrated runner's agent pipeline (unsupported modality, an SPI that refuses an operation, a codec path). The executor's onErrorResume(UnsupportedOperationException.class, ...) cannot tell the two apart before the first onNext, and if it mistakes a real error for "not supported" it re-runs the request through stream() — duplicate model calls, duplicate tool side effects, and the original error disappears from the response.

Suggest making the capability explicit instead of inferring it from an exception type: either a dedicated marker (e.g. UnsupportedAgentEventStreamException extends UnsupportedOperationException thrown only by this default), or a default boolean supportsFineGrainedEvents() { return false; } probe that AgentScopeAgentExecutor.streamAgentEvents checks up front. The executor's emitted flag only narrows the window, it does not close it.

agentCache.put(options.getTaskId(), agent);
return agent.streamEvents(requestMessages)
.doFinally(signal -> agentCache.remove(options.getTaskId()));
agentCache.put(taskId, agent);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

streamWithAgent registers the agent in agentCache at assembly time but only unregisters in doFinally, i.e. at subscribe-terminal time. The fallback path added in this PR can now assemble two streams for the same taskId in one request: streamEvents(...) is assembled (entry created), it terminates, and onErrorResume subscribes stream(...) — whose assembly can race the upstream doFinally cleanup and trip IllegalStateException("Agent already exists for taskId: ...") at line 65. The never-subscribed case is worse: the fineGrainedStream == null branch and any assembly-time throw leave the entry in the cache permanently, after which every later request for that taskId fails.

Could the check + put move inside Flux.defer { ... } (or doOnSubscribe) so registration happens exactly when the stream is subscribed and is always paired with the doFinally removal? That also makes the idempotency guarantee independent of the new fallback path.

|| !legacyEvent.legacyEvent.isLast()) {
return false;
}
return Objects.equals(lastLegacyEventMsgId, legacyEvent.legacyEvent.getMessageId());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dedup compares the incoming legacy message id against the single previously-seen id (lastLegacyEventMsgId, written at line 374). If a legacy runner interleaves message ids (chunk X, chunk Y, terminal X), the terminal X no longer matches lastLegacyEventMsgId (Y), so the completed message is streamed again after the deltas — duplicated text at the A2A client. Conversely, if REASONING and SUMMARY share one reply id, a terminal event can be suppressed even though it carries the authoritative final text.

An id-keyed set of "already streamed non-terminal" ids (rather than the last id only) would make the rule order-independent. A test with two interleaved ids would pin it.

@oss-maintainer

Copy link
Copy Markdown
Collaborator

Two follow-ups to go with my review, one of them raised by @mikemikimike in the last comment and worth an explicit ⬆️ for maintainers.

1. This PR is largely superseded by #2222 (scope question for a maintainer). On main, AgentRunner already declares only Flux<AgentEvent> streamEvents(...) as abstract, and AgentScopeAgentExecutor / BaseReActAgentRunner are already migrated to the fine-grained AgentEvent model. The remaining delta here is therefore the back-compat shim for the deprecated v1 stream() path, not the migration itself. If that shim is judged worth keeping, see point 2; if not, closing in favour of #2222 looks reasonable.

2. The shim as written is source-breaking in the opposite direction. Making stream(...) abstract again (AgentRunner.java:60) while streamEvents(...) becomes a default (line 74) means any custom AgentRunner implemented against the current contract — i.e. one that overrides only streamEvents, which is all main requires — no longer compiles. The compatibility goal is right; the direction is inverted. Defaulting both sides (with stream defaulting to an error signal) preserves both contracts, and a dedicated marker exception instead of a bare UnsupportedOperationException keeps "this runner has not migrated" distinguishable from "this runner failed before its first event" — the latter currently re-executes the request through stream(), with duplicate model calls and tool side effects.

No action needed from the bot side; flagging for maintainer judgement on scope.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Delta re-review at d8e23bf5 (previous pass at 86f870a0). The single commit since then does exactly what the last round asked for, and all three open findings are resolved:

  • F1 capability sentinel (AgentRunner.java:81) - resolved. The streamEvents default now fails with a dedicated UnsupportedAgentEventStreamException (a final subclass of UnsupportedOperationException), and the executor catches only that marker, both synchronously (AgentScopeAgentExecutor.java:190-195) and via onErrorResume (177-189), with an emitted-flag guard (174-182) so the fallback cannot start after any onNext. A genuine UnsupportedOperationException from a migrated runner's pipeline now propagates instead of causing a second execution. testDoesNotFallBackForAgentUnsupportedOperation, testDoesNotFallBackAfterFineGrainedOutput and testFallsBackForSynchronousCapabilityMarker pin all three states.
  • F2 assembly-time cache registration (BaseReActAgentRunner.java:66) - resolved. streamWithAgent is wrapped in Flux.defer, so the agent is built and registered only at subscription time through agentCache.compute (69-79); an existing mapping now raises IllegalStateException instead of silently rebuilding (80-84), unregistration uses the two-arg conditional remove(taskId, agent) (88) so a stale doFinally can no longer evict a newer registration for the same taskId, and a synchronous stream-function failure releases the entry before surfacing the error (85-92). A marker-failing fine-grained stream now registers nothing, so the fallback path can no longer double-register or race the upstream doFinally.
  • F3 legacy dedup (AgentScopeAgentExecutor.java:415) - resolved. Dedup records every legacy chunk that actually produced a response as a LegacyEventKey(EventType, messageId) (372-379) and suppresses an isLast event only when that exact pair was already streamed, so interleaved message ids no longer re-emit a completed message, and the old over-suppression (an AGENT_RESULT clobbering the single remembered id) is gone. testDeduplicatesInterleavedLegacyChunks is a genuine regression test for the previous logic.

Verdict

APPROVE - the reactive semantics are unambiguous now, the cached-agent lifecycle is atomic, and the delta is backed by targeted tests. Two non-blocking notes are pinned inline: one assertion lost its teeth when registration became lazy (AgentRunnerTest.java:371), and the marker-type change deserves a migration note (UnsupportedAgentEventStreamException.java:20).

CI is green on this head (Check License, Check Module Sync, ubuntu + windows builds, codecov) and CLA is signed; mergeState=BLOCKED reflects the missing-review gate only.


Automated review by github-manager-bot

when(mockAgent.streamEvents(messages)).thenReturn(mockFlux);
when(mockFlux.doFinally(any())).thenReturn(mockFlux);
runner.streamEvents(messages, requestOptions);
runner.streamEvents(messages, requestOptions).subscribe();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test vacuity introduced by the deferral change: the sibling tests were fixed by adding blockLast (lines 313/332/354), and .subscribe() was added to the first call on this line, but the re-stream check at the end of the test (Flux<AgentEvent> result = runner.streamEvents(messages, requestOptions);, line 377) is still only assembled, never subscribed. Now that registration is deferred to subscription time, that call cannot fail even if stop() left the entry in agentCache - pre-delta the assembly-time containsKey is what made the assert meaningful. Restub streamEvents to Flux.empty() after runner.stop(taskId) and blockLast the second call so the test actually proves the entry was released.

package io.agentscope.core.a2a.server.executor.runner;

/** Signals that an {@link AgentRunner} has not implemented fine-grained event streaming. */
public final class UnsupportedAgentEventStreamException extends UnsupportedOperationException {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compatibility narrowing worth a changelog line: the documented opt-out signal changed from plain UnsupportedOperationException to this marker, and the executor's synchronous catch was narrowed to the marker type (AgentScopeAgentExecutor.java:190). Catching code is safe (the marker is a subclass), but a third-party runner that manually threw or returned Flux.error(new UnsupportedOperationException(...)) from streamEvents per the previous javadoc no longer triggers the legacy fallback - the request now fails with the raw error. Acceptable while 2.0 is unreleased; please state it in the migration notes and document on AgentRunner.streamEvents that only UnsupportedAgentEventStreamException (or not overriding the method) selects the fallback.

@mikemikimike

Copy link
Copy Markdown
Contributor Author

Addressed the remaining review follow-ups in commit b5a58287:

  • AgentRunnerTest now subscribes to the second stream with blockLast(), so lazy re-registration after stop() is actually exercised.
  • Added migration guidance for UnsupportedAgentEventStreamException versus a plain UnsupportedOperationException in the AgentRunner Javadoc and v2 English/Chinese changelogs.

Validation:

  • Focused A2A tests: 44 passed.
  • mvn -pl agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-a2a/agentscope-extensions-a2a-server -am clean verify: core 2,323 tests (8 existing skips), A2A client 111, A2A server 146.
  • GitHub checks: 7/7 passed, including Ubuntu and Windows builds.
  • git diff --check passed.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Delta re-review at b5a58287 (previous pass approved d8e23bf5). The single new commit is exactly the follow-up the last round asked for and touches no production behavior:

  • AgentRunner.streamEvents(...) javadoc now states the legacy-fallback contract explicitly (default implementation / marker exception vs. a real UnsupportedOperationException).
  • AgentRunnerTest.testStopAgentAndRemoveFromCache now subscribes to the second stream (blockLast()) instead of asserting on an un-subscribed Flux, so the "agent was removed from the cache" claim is actually exercised.
  • Both change-log.md copies (en + zh) document the same rule.

Implementation side is unchanged and still correct: AgentScopeAgentExecutor.streamAgentEvents(...) narrows both the eager catch and the onErrorResume to the dedicated UnsupportedAgentEventStreamException subclass, so a genuine UnsupportedOperationException from the agent pipeline propagates rather than silently re-executing the agent through the legacy path. CLA signed, 6/6 checks green, mergeable=true / clean → re-approving on the current head.

Findings

  • [Info] AgentRunner.java:74 — the new sentence omits the "before any element is emitted" guard that the executor really applies (anchored inline).
  • [Info] docs/v2/en/docs/change-log.md:210 — the A2A note is inserted between the colon lead-in and the deprecation bullet list; same layout in the zh copy (anchored inline).

Both are documentation wording nits, non-blocking.


Automated review by github-manager-bot

* custom runners remain source-compatible whether they implement this method or only the
* legacy {@link #stream(List, AgentRequestOptions)} contract.
* The A2A executor selects the legacy stream only when this default implementation is used or
* the returned Flux terminates with {@link UnsupportedAgentEventStreamException}; a plain

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] Minor accuracy gap in the new fallback contract sentence. AgentScopeAgentExecutor.streamAgentEvents(...) also guards on whether anything has already gone downstream (emitted flag + onErrorResume(UnsupportedAgentEventStreamException.class, ...)): if the fine-grained stream emits at least one element and then fails with the marker, the error is propagated instead of re-running through the legacy stream(...). Worth saying "...terminates with UnsupportedAgentEventStreamException before any element is emitted", otherwise a custom-runner author could reasonably assume late fallback is available. Not blocking — the behavior itself is the right one (falling back after partial output would duplicate text/tool results).

Comment thread docs/v2/en/docs/change-log.md Outdated

Python 2.0's `agent.reply_stream()` exposes a single streaming signature (`AsyncGenerator[AgentEvent, None]`) that maps directly to Java's fine-grained `io.agentscope.core.event.AgentEvent` hierarchy. To match it, the coarse-grained `Flux<Event> stream(...)` API on the Java side is `@Deprecated` as of 2.0.0:

For A2A `AgentRunner` implementations, leave `streamEvents(...)` unoverridden or return `UnsupportedAgentEventStreamException` when the fine-grained stream is unavailable to select the legacy `stream(...)` fallback. A plain `UnsupportedOperationException` from an overridden `streamEvents(...)` is treated as an execution failure and is not retried through the legacy stream.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] This A2A paragraph is inserted between the lead-in sentence ("... is @Deprecated as of 2.0.0:") and the bullet list it introduces, so the colon now reads as if it introduces the A2A note. Same layout in docs/v2/zh/docs/change-log.md. Suggest moving the note below the list (or as a **Note** block after it) so the deprecation list stays attached to its intro. Docs only, not blocking.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Delta re-review at 4c5e1a3f (previous pass approved b5a58287). The single new commit is a javadoc/documentation clarification of the A2A stream-fallback contract, and it is accurate against the shipped code:

  • AgentRunner.streamEvents(...) now states the legacy fallback is selected only when the default implementation is used or the stream fails with UnsupportedAgentEventStreamException before any element is emitted. That is exactly what AgentScopeAgentExecutor.streamAgentEvents(...) does: the AtomicBoolean emitted guard re-emits the error instead of falling back once anything went downstream, so a mid-stream failure can never cause a second agent execution.
  • The A2A paragraph in docs/v2/en/docs/change-log.md and docs/v2/zh/docs/change-log.md moved below the forRemoval bullet list; wording is unchanged and en/zh stay in sync.

No production behavior is touched. CI on this head is green (Linux + Windows build, license, module sync, codecov, validate) and license/cla is signed. Re-approving — nothing further to ask.


Automated review by github-manager-bot

@mikemikimike mikemikimike changed the title feat(a2a): expose fine-grained agent event stream fix(a2a): map HITL confirmation events to A2A Sep 25, 2026

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Delta re-review at 9bad5d2a (previous pass approved 4c5e1a3f). The branch was rebased onto current main and then gained a substantial new commit: A2A now maps RequireUserConfirmEvent to an INPUT_REQUIRED task carrying an agentscope.confirmation_request DataPart, and accepts an agentscope.confirmation_response DataPart to resume. Net new production code on top of the previously approved state is AgentScopeAgentExecutor (+340) and BaseReActAgentRunner (+101/−14).

What looks genuinely good:

  • Resume validation is strict in the right places — replyId must match the pending request, toolCallId must reference a known pending call, duplicates are rejected, confirmed must be a real boolean, result.toolCall.name is pinned to the requested name, and at most one control DataPart per message. Rejecting confirmation_response on non-INPUT_REQUIRED tasks closes the replay hole.
  • The concatWith deferral (emit the request only after the agent has actually finished pausing, plus the "Agent was stopped before confirmation could be requested" check) is the correct ordering fix, and clearing the paused flag inside compute is atomic.
  • The earlier threads on this PR are resolved by the rewrite: UnsupportedAgentEventStreamException and the capability-sentinel double duty are gone, and registration moved into Flux.defer, so it is no longer done at assembly time.
  • Tests track the new contract closely (AgentScopeAgentExecutorTest +410, AgentRunnerTest +86/−13), CI is green on this head (7/7), license/cla is signed, and the rebase landed clean (MERGEABLE/CLEAN) with no conflict residue.

Findings (5, no blockers) are about error surfacing and lifecycle rather than the happy path:

  • [Warning] Protocol-validation failures in convertInputMessage bypass the task lifecycle — the client gets a bare text message and the task stays INPUT_REQUIRED, so a rejected resume is indistinguishable from a hung one (AgentScopeAgentExecutor.java:148).
  • [Warning] "confirmation more than once" fails a run the core explicitly allows: ReActAgent.validateAndAcceptConfirmResults permits confirming a subset of ASKING calls, and combined with the point above the second pause strands the task (AgentScopeAgentExecutor.java:550). Fine to keep the single-pause contract, but it should be documented and enforced on the resume side.
  • [Warning] SignalType.ON_COMPLETE retention gives paused agents no expiry, so abandoned confirmations leak a live ReActAgent each; CachedAgent.paused is additionally a plain non-volatile boolean read outside the map lock (BaseReActAgentRunner.java:127).
  • [Info] Client-edited tool arguments are executed by design — worth one explicit change-log line, plus a pinned contract for clearing input (AgentScopeAgentExecutor.java:299).
  • [Info] The resume handshake requires the task store to preserve status.message(), which should be stated as a durability requirement (AgentScopeAgentExecutor.java:229).

Holding this pass at COMMENT rather than re-approving, since the previous APPROVED was recorded against 4c5e1a3f and the multi-round / cache-lifecycle contract above is worth an explicit author decision first. Happy to re-approve on the next push.


Automated review by github-manager-bot

@@ -134,6 +148,242 @@ public void execute(RequestContext context, EventQueue eventQueue) throws JSONRP
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] Protocol-validation failures never reach the task lifecycle.

convertInputMessage(context) runs before the task is looked up/created, and the outer catch (Exception e) only enqueues a plain text message (A2A.createAgentTextMessage(...)). For the new rejection paths in convertInputMessage (missing / duplicate confirmation_response DataPart, replyId mismatch, unknown toolCallId, non-boolean confirmed, ...) the client therefore gets:

  1. a message that is not a TaskStatusUpdateEvent, so a task-watching client cannot tell that the resume was rejected;
  2. the task left in INPUT_REQUIRED with no state update — the client will just retry the same payload;
  3. an error string built from e.getMessage(), which for these new validations echoes field names and (indirectly) values from the client payload into the log and the returned message.

Since every one of these is a caller error, it would be nicer to map them to InvalidParamsError (or whatever the JSON-RPC error type for the a2a SDK is) and/or emit taskUpdater.failed(...) so the task terminates in a well-defined state. A cheap middle ground: run convertInputMessage after the task resolution and reuse the processTaskNonBlocking catch style (taskUpdater.fail(...)) for the resume path.

LoggerUtil.debug(
log, "[{}] Handle Agent execute output event: {}", context.getTaskId(), output);
if (output instanceof RequireUserConfirmEvent request) {
if (!waitingForInput.compareAndSet(false, true)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] A second HITL pause aborts a run that the core explicitly allows to continue.

Both guards here and in BaseReActAgentRunner.streamEvents ("requested user confirmation more than once") make the A2A layer fail hard on a second RequireUserConfirmEvent within one stream. ReActAgent.validateAndAcceptConfirmResults documents that "Confirmations may cover a subset of ASKING calls", so a client that confirms 1 of 2 ASKING tool calls (or that approves a batch which then hits another gated tool) reaches this branch. Combined with finding #1 the outcome is a task stuck in INPUT_REQUIRED: the stream errors, doOnError writes a text message, and no terminal task state is ever set, so the agent progress made before the second pause is lost.

Two options, either is fine, but the chosen contract should be explicit:

  • per-stream single pause (current): say so in the AgentRunner.streamEvents javadoc + change-log.md entry, and make the resume side reject partial confirmation batches up front (parseConfirmationResults can require results to cover every toolCalls id) so the second event is genuinely unreachable; or
  • multi-round: keep the pending confirmation_request DataPart in the task status message and allow N pauses per task by re-arming waitingForInput when a resume is accepted (the runner already re-uses the cached agent across resumes, so the plumbing is half-way there).

Also worth noting: because BaseReActAgentRunner already filters the event and emits at most one, the guard here is only reachable for custom AgentRunner implementations — the two layers fail differently (Mono.error vs. a raw throw inside doOnNext, which in the blocking path escapes blockLast()). Aligning them would keep the error surface predictable.

}))
.doFinally(
signal -> {
if (signal != SignalType.ON_COMPLETE

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] Paused agents are retained with no expiry, and SignalType.CANCEL keeps them too.

doFinally keeps the CachedAgent on ON_COMPLETE when a confirmation was requested — correct for resume — but nothing bounds its lifetime afterwards:

  • a client that receives INPUT_REQUIRED and never resumes leaks one live ReActAgent (context, memory, model/tool clients) per abandoned task, forever;
  • signal != ON_COMPLETE covers ERROR but a cancelled subscription after the request was emitted also removes/keeps the entry depending on where the cancel lands, which makes the follow-up resume either fail with "Agent already exists for taskId" (a new, unpaused entry is built) or reuse a stale agent non-deterministically.

The old code had the same shape, but the pause semantics make the leak much easier to hit in production (users routinely walk away from a confirmation prompt). Suggested direction: store pausedAt in CachedAgent and evict expired entries on streamEvents/stop (a small Map sweep is enough — this runs per request anyway), and/or drain abandoned tasks through cancel(RequestContext, EventQueue).

Minor, same class: CachedAgent.paused is a plain mutable boolean written inside compute/computeIfPresent and read in doFinally, i.e. outside the map's bin lock. AtomicBoolean (or making the cached value an immutable record with a paused copy) removes the data race.

throw new IllegalArgumentException(
"result.toolCall.name must match the requested tool call.");
}
if (modifiedToolCall.containsKey("input")) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] Client-supplied tool-call edits are passed through verbatim.

The requested tool call is copied and then input is replaced with whatever the client sent, and the resulting ToolUseBlock is handed to ConfirmResult for execution. name is pinned to the original, which is the important part, but the arguments are fully attacker-controlled by design here (that is the point of "edit before approving"), so this only needs to be explicit rather than implicit:

  • a short note in the change-log.md entry stating that results[].toolCall.input replaces the model's arguments, so reviewers of the A2A surface know the permission decision covers edited inputs;
  • if (modifiedToolCall.containsKey("input")) cannot clear the input (input: null falls through toStringMap as an error). Treating an explicit "input": {} as the clear-path is enough, but worth a test so the contract is pinned.

No action needed if this is already the documented AG-UI/permission-engine behaviour — the AG-UI converter does the same (editedArgs overrides both input and content). Keeping the two surfaces consistent is the ask.


private static PendingConfirmation findPendingConfirmation(Task task) {
Message statusMessage = task.getStatus().message();
DataPart requestPart = findControlPart(statusMessage, CONFIRMATION_REQUEST_TYPE);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] The resume handshake depends on the task store keeping status.message() intact.

findPendingConfirmation reads the confirmation_request DataPart back out of task.getStatus().message(). That works with an in-memory / message-preserving store, but a task store that trims or rewrites the status message (persistence to a DB with a message column dropped on upsert, or a store that replaces INPUT_REQUIRED with a newer status update) turns a legitimate resume into "The INPUT_REQUIRED task does not contain an AgentScope confirmation request."

Worth one line in the change-log/docs on the durability requirement for the confirmation request payload (or a fallback to rebuild the pending batch from the agent state, which the runner can already reach through the cached agent).

@mikemikimike

Copy link
Copy Markdown
Contributor Author

Addressed the five findings from the latest review in commit 2a053c72.

I chose the single-pause-per-stream contract: each confirmation response must cover every pending tool call exactly once. A later resume uses a new stream and can request another complete batch. Invalid resume data now emits a final FAILED task status and stops the retained agent. Paused agents expire after 30 minutes by default (configurable), with identity-based cleanup to avoid an old expiry removing a resumed agent.

The English and Chinese changelogs now state that edited toolCall.input replaces the requested arguments, {} clears them, and the task store must preserve the status message containing the pending request. Regression tests cover these paths and the pause/stop/resume lifecycle. The focused A2A tests, A2A server reactor clean verify, and all seven current GitHub checks pass.

Please re-review the updated head when convenient.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This PR maps AgentScope RequireUserConfirmEvent to A2A INPUT_REQUIRED tasks, validates confirmation responses, and resumes paused ReActAgent instances while adding a 30-minute paused-agent retention. The core logic is sound: reply validation is strict, invalid resumes fail the task and stop the agent, and the runner defers registration and handles expiry. However, there are two remaining correctness/cleanup issues around blocking resumes and agent lifecycle cleanup.

Findings

  • [Warning] agentscope-extensions/.../AgentScopeAgentExecutor.java:141 — Blocking confirmation resumes are forced through the streaming/non-blocking path because the branch requires context.getTask() == null. A blocking tasks/send resume for an INPUT_REQUIRED task should be handled by processTaskBlocking so the client receives the expected single-response events (Message or Task), not streaming TaskStatusUpdateEvents. Remove the context.getTask() == null guard from the blocking branch or add an explicit blocking-resume handler.
  • [Warning] agentscope-extensions/.../BaseReActAgentRunner.java:178 — stop() only calls agent.close() when the cached agent is paused. For a running (non-paused) agent, stop() removes it from the cache and interrupts it but never closes it. Because doFinally later calls removeAgent(taskId, cachedAgent) and the cache no longer contains that entry, removeAgent returns false and also skips close(). Running agents stopped via stop() therefore leak the resources released by ReActAgent.close(). Always close the agent in stop() (in addition to interrupting), or ensure doFinally closes even when the cache entry is already gone.
  • [Info] agentscope-extensions/.../AgentScopeAgentExecutorTest.java:478 — The new resume tests all use streaming contexts. Add a test that resumes an INPUT_REQUIRED task with a blocking request configuration to verify the blocking response path.

Suggestions

  • Add a blocking-resume test and route blocking resumes through processTaskBlocking.
  • Make BaseReActAgentRunner.stop() always close the agent (or make removeAgent close the captured agent regardless of cache state) so stopped running agents release resources deterministically.

Automated review by github-manager-bot

log.info("[{}] Using existing task.", task.getId());
}
if (isBlockRequest(context)) {
if (isBlockRequest(context) && context.getTask() == null) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking confirmation resumes are forced through the streaming/non-blocking path because the branch requires context.getTask() == null. A blocking tasks/send resume for an INPUT_REQUIRED task should be handled by processTaskBlocking so the client receives the expected single-response events (Message or Task), not streaming TaskStatusUpdateEvents. Remove the context.getTask() == null guard from the blocking branch or add an explicit blocking-resume handler.

}

@Override
public void stop(String taskId) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stop() only calls agent.close() when the cached agent is paused. For a running (non-paused) agent, stop() removes it from the cache and interrupts it but never closes it. Because doFinally later calls removeAgent(taskId, cachedAgent) and the cache no longer contains that entry, removeAgent returns false and also skips close(). Running agents stopped via stop() therefore leak the resources released by ReActAgent.close(). Always close the agent in stop() (in addition to interrupting), or ensure doFinally closes even when the cache entry is already gone.

@ParameterizedTest
@ValueSource(booleans = {false, true})
@DisplayName("Should convert a confirmation response into validated AgentScope metadata")
void testStreamingRequestConvertsConfirmationResponse(boolean clearInput)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new resume tests all use streaming contexts (doMockForContext(false, false, true) or doMockForContext(true, false, false)). Add a test that resumes an INPUT_REQUIRED task with a blocking request configuration to verify the blocking response path (currently routed to processTaskNonBlocking at line 141).

@mikemikimike

Copy link
Copy Markdown
Contributor Author

Addressed the blocking-resume and running-agent cleanup findings in b256cf81.

  • Blocking requests now select processTaskBlocking for both new tasks and existing INPUT_REQUIRED tasks.
  • stop() closes the removed agent in finally, including running agents.
  • The confirmation-response regression test covers blocking and streaming requests with edited and empty tool inputs. It asserts a single Message response with the correct task/context IDs for blocking resumes. The running-stop test now verifies close().

The new regression assertions fail against the previous production logic (three failures) and pass with the fix. spotless:check clean verify passed for the A2A server reactor: core 2,462 tests (9 existing skips), A2A client 111 tests, A2A server 152 tests.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Re-reviewed head b256cf81 (one commit since my 2a053c72 review). Both open items from last round are resolved: blocking requests on an existing task now go through processTaskBlocking instead of being forced onto the streaming path, and BaseReActAgentRunner.stop() closes the cached agent unconditionally, so a stopped running agent no longer leaks the resources released by ReActAgent.close(). The resume test was also converted to a @CsvSource matrix over streaming/blocking with assertions on the emitted event shape, and AgentRunnerTest now verifies close() — exactly the coverage gap I had flagged. Approving; the two inline notes are follow-ups, not blockers.

Findings

  • [Info] BaseReActAgentRunner.java:185 — double-close safety relies on cache-removal ordering; worth a comment.
  • [Info] AgentScopeAgentExecutor.java:141 — blocking resumes now wait for the full run on the request thread; add a change-log entry.

Checks

  • CI: validate, Check License, Check Module Sync, build (ubuntu-latest), build (windows-latest), codecov/patch, license/cla — all passing at review time.
  • CLA: signed.

Automated review by github-manager-bot

Findings

  • [Info] agentscope-extensions/.../runner/BaseReActAgentRunner.java:185 — Unconditional close() in stop() fixes the running-agent leak. Double-close is avoided because stop() removes the entry with agentCache.remove(taskId) before the stream's doFinally reaches removeAgent(taskId, cachedAgent), whose conditional remove(key, value) then returns false. That safety depends on the cache-removal ordering rather than on close() being idempotent — one comment stating so would keep the next refactor from reordering it.
  • [Info] agentscope-extensions/.../executor/AgentScopeAgentExecutor.java:141 — Dropping the context.getTask() == null guard makes blocking resumes go through processTaskBlocking, which is what the previous round asked for. processTaskBlocking ends in blockLast() on the request thread, so a blocking resume of an INPUT_REQUIRED task now waits for the whole agent run instead of returning once the confirmation is accepted. That matches A2A blocking semantics, but it is a latency-visible change for existing callers and deserves a line in docs/v2/{en,zh}/docs/change-log.md next to the HITL mapping entry.

Inline anchoring was rejected by the review API on this commit, so both notes are listed here instead of as line comments.

@mintlify

mintlify Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Preview approval required

This pull request comes from a fork, so Mintlify won't build a preview until someone with write access approves commit a742160.

Approve preview

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/ext/integration External protocols & middleware integrations enhancement New feature or request

Projects

None yet

5 participants