Skip to content

fix(core): emit tool result events for user-denied HITL calls - #3104

Open
CryoThrust wants to merge 1 commit into
agentscope-ai:mainfrom
CryoThrust:fix/hitl-denied-events
Open

fix(core): emit tool result events for user-denied HITL calls#3104
CryoThrust wants to merge 1 commit into
agentscope-ai:mainfrom
CryoThrust:fix/hitl-denied-events

Conversation

@CryoThrust

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

When a user denies a permission-mode HITL tool call, the resumed run writes a DENIED ToolResultBlock to context but emits no ToolResultStartEvent/ToolResultTextDeltaEvent/ToolResultEndEvent. This leaves AG-UI and other event-driven consumers unable to observe the outcome. Rule-denied calls already emit the corresponding sequence.

What changed

  • Carry the persisted HITL reply id into confirmation-result application.
  • Emit the same start → text delta → end sequence for user-denied calls, with ToolResultState.DENIED.
  • Add an end-to-end regression test covering correlation, ordering, tool-call id, and denied state.

Verification

  • mvn -pl agentscope-core -DskipTests -Dspotless.check.skip=false spotless:check
  • mvn -pl agentscope-core -Dtest=ReActAgentHitlTest test (14 tests passed)

Signed-off-by: Yohanes CryoThrust@users.noreply.github.com

Signed-off-by: Yohanes <CryoThrust@users.noreply.github.com>
@codecov

codecov Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.00000% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...e/src/main/java/io/agentscope/core/ReActAgent.java 90.00% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@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

Emitting the missing ToolResultStartEvent / TextDeltaEvent / ToolResultEndEvent triple for user-denied HITL tool calls is the right fix, it mirrors the existing rule-denied emission in runToolBatch, and it ships with a focused regression test. Left as COMMENT because two questions are open (blank-replyId no-op, and publishing events from a synchronous helper rather than the stream).

Findings

  • [Warning] ReActAgent.java:2014 — the blank-replyId guard silently emits nothing, so the stuck-pending symptom survives for older persisted sessions.
  • [Warning] ReActAgent.java:1916 — events are published outside the reactive chain; please confirm subscriber ordering.
  • [Info] ReActAgent.java:2022 — duplicate literal for the denial reason.
  • [Info] test — no coverage for the blank-replyId branch or the auto-denied path.

Cross-PR Note

PR #3099 (also "emit permission-denied tool result events") overlaps heavily with this change and takes a different approach: it extracts a shared deniedToolResultEvents(...) helper, covers the auto-denied path, and mints a reply id when the correlation metadata is missing. Maintainers may want to land one of the two and fold the useful parts of the other in, rather than both.


Automated review by github-manager-bot

ToolResultMessageBuilder.buildToolResultMsg(
deniedResult, denied, getName());
state.contextMutable().add(deniedMsg);
if (replyId != null && !replyId.isEmpty()) {

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.

Skipping emission whenever replyId is blank leaves exactly the symptom this PR fixes: for an ASKING tool call persisted before the correlation metadata existed (or after clearPendingRequestReplyId), no ToolResult* events are published, so an AG-UI client still renders a permanently pending tool call. Could this path emit under a freshly minted reply id (as PR #3099 does) or at least log at warn level so the silent no-op is diagnosable?

replyId,
denied.getId(),
denied.getName(),
"Permission denied by user"));

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 reason string is now duplicated in two places (ToolResultBlock.text(...) above and this delta). If they ever drift, the message written to context and the one streamed to the client disagree. Worth extracting a private static final String DENIED_BY_USER = "Permission denied by user"; and reusing it in both spots.

}

applyConfirmResults(normalized);
applyConfirmResults(normalized, replyId);

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.

applyConfirmResults is a synchronous state-mutation helper, so these events are pushed through publishEvent while the resume call is still validating input, i.e. outside the reactive chain that emits RequireUserConfirmEvent / RequestStopEvent. Please confirm the ordering guarantees: a subscriber that starts collecting after validateAndAcceptConfirmResults returns (or a replay from persisted state) may never observe the denied triple. Returning Flux<AgentEvent> from the helper — as runToolBatch does — would keep emission inside the stream.

assertEquals("tc1", confirm.getConfirmResults().get(0).getToolCall().getId());
}

@Test

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.

Nice regression test. Two gaps worth covering: (1) resume without METADATA_CONFIRM_REQUEST_REPLY_ID (the blank-replyId branch, which today emits nothing), and (2) the rule-denied path — writeAutoDeniedResults still writes DENIED blocks without publishing events, so an auto-denied tool has the same stuck-pending symptom this PR addresses for user denials.

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.

2 participants