Skip to content

fix(harness): resolve ask_user conflicts with current main - #3108

Open
mikemikimike wants to merge 13 commits into
agentscope-ai:mainfrom
mikemikimike:submit/feat-ask-user-tool
Open

fix(harness): resolve ask_user conflicts with current main#3108
mikemikimike wants to merge 13 commits into
agentscope-ai:mainfrom
mikemikimike:submit/feat-ask-user-tool

Conversation

@mikemikimike

@mikemikimike mikemikimike commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

This is a conflict-resolved continuation of PR #2865 for the built-in ask_user HITL direction. The original PR branch is owned by another fork, so this branch carries the feature onto the current upstream main from mikemikimike/agentscope-java.

Changes

  • Preserved the core pause/resume flow, structured ask-user events/results, BYPASS-mode protection, harness builder switch, tests, documentation, and example from PR feat(harness): built-in ask_user tool — model-initiated questions to the user (HITL ask direction) #2865.
  • Evaluated tool-specific ASK_USER decisions before the EXPLORE / ACCEPT_EDITS read-only shortcut, so model-initiated questions pause in every permission mode.
  • Masked secret answers in model-visible tool results and UserAskResultEvent, including unknown or mismatched answer keys when a secret question is present. Answer insertion order and null answers remain supported, and AskUserResult.toString() does not expose answer contents.
  • Persisted the resolved permission behavior on paused tool calls and added recovery from the built-in ask_user shape or a tool permission self-check when message metadata is missing, so reloaded or compacted sessions can still resume correctly.
  • Mixed ASK and ASK_USER calls in one model batch now emit both pause events with GenerateReason.PERMISSION_AND_ASK_USER_ASKING and resume independently instead of aborting the turn. Answered calls are marked finished, and auto-denied results are persisted before pausing.
  • Updated the Aistio, Agent Protocol, and managed-service consumers to recognize the combined pause explicitly. Permission-only bridges select only the permission-confirmation subset and do not report an unsupported ASK_USER subset as ordinary completion.
  • Kept question-id validation for answer correlation and secret redaction.

Tests

  • D:\CI\run-pre-pr.ps1 Java 17 targeted Core validation passed for ReActAgentAskUserTest, GenerateReasonTest, and PermissionRuleTest.
  • D:\CI\run-pre-pr.ps1 Java 17 targeted Agent Protocol validation passed: 4 tests, 0 failures, 0 errors.
  • D:\CI\run-pre-pr.ps1 Java 17 targeted Aistio validation passed: 6 tests, 0 failures, 0 errors.
  • D:\CI\run-pre-pr.ps1 Java 17 targeted service-dataplane validation passed: 9 tests, 0 failures, 0 errors.
  • D:\CI\run-pre-pr.ps1 Java 17 targeted Harness ask-user validation passed: 5 tests, 0 failures, 0 errors, including askUserPausesEvenUnderBypassMode.
  • git diff --check and Spotless checks passed for the follow-up changes.
  • The current full Harness pre-PR run executed 1,045 tests with 0 assertion failures and 1 environment-specific error in MarketplaceStagerOrphanGcTest.unreadableEntryDoesNotFailStage: a temporary .skills-cache path raised NoSuchFileException under the Docker/POSIX-permission runner. The targeted ask-user test passed in the same runner, and the same Marketplace error was observed before this follow-up.
  • The fresh GitHub Actions run 34672047314 used this commit. License, module-sync, Mintlify, and CLA checks passed, and AguiPermissionResumeTest passed all four parameter combinations. The Ubuntu Harness job ran 1,045 tests with 0 assertion failures and 1 error while closing the JUnit extension context for HarnessAgentAskUserTest.askUserPausesEvenUnderBypassMode; the targeted rerun passed locally, indicating a non-deterministic test-runner failure rather than a reproducible failure in this change. The Windows job was cancelled by fail-fast.
  • The preceding run 34666338212 on the prior commit had the known active-run race tracked in #3109; that test passed in the fresh run above. GitHub does not allow this fork account to rerun the old workflow because repository-admin permission is required.

Compatibility / Known limitations

  • ask_user remains opt-in through HarnessAgent.Builder.enableAskUser(); existing builders retain their behavior.
  • The follow-up adds the combined pause classification and explicit handling in the three previously identified consumers. It does not add a durable ASK_USER resume protocol to AG-UI, Agent Protocol, Aistio, or managed service. Those bridges now refuse or report unsupported question pauses explicitly rather than treating them as successful completion; a dedicated question-resume transport remains a separate follow-up.
  • ToolUseBlock.metadata is persisted and may be wire-visible provider/framework-specific metadata. agentscope.permissionBehavior is an internal marker used for pause recovery and mixed-batch routing; integrations should preserve it when persisting/resuming and should not treat it as a provider contract.
  • RequireUserAskEvent and UserAskResultEvent are additive event types. Older agentscope-core peers cannot decode payloads carrying the new event names during a rolling upgrade.
  • Secret values are intentionally redacted from framework-generated model/event output; hosts should retain any sensitive value only in their own protected input flow.

Issue link

Closes #2860

Letter2025 and others added 8 commits August 27, 2026 17:59
Adds the HITL ask direction: a tool whose checkPermissions() returns
PermissionDecision.askUser(...) pauses the run with a new
GenerateReason.ASK_USER_ASKING (in every permission mode, including BYPASS)
and is never executed; callers render the questions and resume with
AskUserResult(s) under Msg.METADATA_ASK_USER_RESULTS.

Core:
- PermissionBehavior.ASK_USER + PermissionDecision.askUser()
- PermissionEngine: ASK_USER short-circuits the bypass-immune tool self-check
- ReActAgent: gate routes ASK_USER to a dedicated pending list; actingStream
  emits RequireUserAskEvent + RequestStopEvent(ASK_USER_ASKING); acting()
  returns the pausing assistant Msg unchanged; doCallInner accepts and
  formats AskUserResults into the ask_user tool result on resume
- New events: RequireUserAskEvent / UserAskResultEvent (replyId-correlated)
- GenerateReason.ASK_USER_ASKING

Harness:
- Built-in AskUserTool (ask_user, JSON-schema'd questions[])
- HarnessAgent.Builder.enableAskUser() (opt-in, off by default)

Docs + examples + tests:
- v1 hitl.md / v2 permission-system.md (en+zh) document the ask direction
- AskUserHITLExample runnable example
- ReActAgentAskUserTest (core) + HarnessAgentAskUserTest (harness) cover
  pause under BYPASS, batch questions, skip semantics, resume without
  executing the tool

Closes agentscope-ai#2860
… new events

- AskUserResult gains @JsonCreator/@JsonProperty so UserAskResultEvent
  decodes through the RemoteEventCodec AGENT_EVENT passthrough
- RemoteEventCodecPassthroughTest covers REQUIRE_USER_ASK / USER_ASK_RESULT
- PermissionBehaviorTest expects five behaviors (ASK_USER added)
…ests

Default memory hooks schedule background flush/maintenance after each call;
disabling them keeps the AskUser harness tests deterministic and free of
post-call background threads in the shared surefire JVM.
…7153)

HarnessAgentSubagentStreamTest.call_localSubagent_returnsReplyWithoutStreaming
fails on the CI runner with 'Failed to close extension context' at @tempdir
teardown. The same failure occurred on main's guava-bump CI (no relation to
this PR); the test passes locally. No code change in this commit.
- AskUserEventsTest: full-arg constructors, getters, Jackson round-trips and
  null-safe payloads for RequireUserAskEvent / UserAskResultEvent; @JsonCreator
  round-trip + edge branches of AskUserResult (empty list, selected-only,
  text-only, skipped=false, scalar values, multi-entry)
- ReActAgentAskUserTest: resume validation error branches (duplicate answer
  id, unknown/stale tool call id)
- HarnessAgentAskUserTest: AskUserTool.callAsync fallback covers the
  interactive placeholder; AskUserTool fallback text now carries the real
  metadata key value

@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 is the conflict-resolved successor of #2865 and wires a second HITL direction (model-initiated ask_user) through PermissionBehavior.ASK_USER, two new events, and an opt-in harness builder flag. The structure is good and the new tests are thorough for the default/BYPASS paths, but the interrupt is not actually mode-exhaustive, and the answer channel has a couple of correctness / credential-handling problems I would like resolved before this is merged.

Findings

  • [Critical] PermissionEngine.java:159ASK_USER never reaches the engine in EXPLORE / ACCEPT_EDITS mode, because checkExploreMode() short-circuits read-only tools to ALLOW before tool.checkPermissions() runs; ask_user then executes its fallback callAsync() and the question is silently swallowed instead of pausing.
  • [Warning] AskUserTool.java:138 — the secret question type cannot be honoured: raw answers are written into the model-visible tool result (persisted context/state) and carried verbatim on UserAskResultEvent.
  • [Warning] AskUserResult.java:50Map.copyOf rejects null answer values (contradicting the documented (no answer) / skip handling in formatAnswerValue) and does not preserve question order.
  • [Warning] ReActAgent.java:3035 — the ask-user early return skips writeAutoDeniedResults() and never emits RequireUserConfirmEvent for ASK calls in the same batch.
  • [Warning] ReActAgent.java:1775 — the ASK_USER branch is checked before the confirmation branch and validates ids against all ASKING calls, so an answer payload can consume a pending permission confirmation.
  • [Warning] GenerateReason.java:72 — consumers that special-case PERMISSION_ASKING (service-dataplane SessionTurnRunner, aistio HarnessAgentTaskStarter, AgentProtocolTaskStore, the AG-UI converters, RemoteEventCodec) were not updated; please say what is intentionally out of scope.
  • [Info] PermissionBehavior.java:39 and ReActAgent.java:1973/2017 — rule registration silently ignores ASK_USER; null-id handling in isInAny; answered tool calls keep ASKING state; import and @SuppressWarnings nits; the title would read better as feat(...).

Suggestions

  1. Make the interrupt genuinely mode-exhaustive: evaluate tool.checkPermissions() before the explore/accept-edits read-only shortcut (or exclude ASK_USER from that shortcut), and add a PermissionMode x pause-kind matrix test — today only default and BYPASS are asserted.
  2. For secret answers, resolve the question type from the paused ToolUseBlock input (the tool call still carries questions[]) and mask the value both in the formatted tool result and in UserAskResultEvent; also drop answers from AskUserResult.toString(). If masking is out of scope here, remove secret from the enum for now rather than advertise a guarantee the runtime breaks.
  3. Prefer Collections.unmodifiableMap(new LinkedHashMap<>(answers)) over Map.copyOf so skipped/null answers render instead of throwing, and the answer text keeps the model question order.
  4. Reconcile the mixed-batch case: write auto-denied results before returning, and either emit RequireUserConfirmEvent alongside RequireUserAskEvent or document that a batch may not mix the two pause kinds. Keying the persisted pending state by pause kind (for example also persisting the asked tool-call ids next to the reply id) would let each resume path validate only its own payload.
  5. The description reports about 1,038 harness tests passing but the full reactor build was cut short by dependency-download interruptions. Could you confirm a clean run of the agentscope-core tests plus the extensions that switch on GenerateReason, and note the MarketplaceStagerOrphanGcTest failure separately so reviewers can see it is pre-existing and environment-specific?

Cross-repo Note

RequireUserAskEvent / UserAskResultEvent are additive entries in the AgentEvent @JsonSubTypes registry: a peer running an older agentscope-core (studio or AG-UI frontends, distribution nodes during a rolling upgrade) cannot decode payloads carrying the new names. Worth a compatibility note in the docs page added here, since the same text ships to both docs/v1 and docs/v2.


Automated review by github-manager-bot

return toolCheckPermissions(tool, input)
.flatMap(
toolDecision -> {
if (toolDecision.getBehavior() == PermissionBehavior.ASK_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.

ASK_USER is short-circuited here, but this flatMap only sees the decision when toolCheckPermissions() actually reaches tool.checkPermissions(). In EXPLORE and ACCEPT_EDITS it does not: toolCheckPermissions() first calls checkExploreMode(tool), and because AskUserTool is built with .readOnly(true) that helper returns ALLOW immediately, so the tool's own checkPermissions() is never invoked. Result: in those two modes ask_user does not pause at all — callAsync() runs and the model gets the fallback text ("This tool is handled interactively by the host application..."), so the question is silently swallowed. The javadoc on AskUserTool / PermissionDecision.askUser() claims the interrupt happens "in every PermissionMode, including BYPASS"; BYPASS is indeed covered (the bypass fallback lives in continueAfterToolCheck), but EXPLORE/ACCEPT_EDITS are not. Suggested fix: consult the tool's own decision before the explore-mode shortcut (or let checkExploreMode skip tools whose checkPermissions may return ASK_USER), and add a mode matrix test — the new tests only cover the default and BYPASS paths.

"default",
"single",
"description",
"single=choose one option; multiple=choose any number of 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.

The secret question type advertises "the host must not persist or log the raw value", but the framework itself cannot honour that. validateAndAcceptAskUserResults() renders the answers through AskUserResult.formatAnswers() into a ToolResultBlock that is appended to the agent context — i.e. persisted into the session state store and re-sent to the model on every following iteration — and UserAskResultEvent carries the same raw AskUserResult objects to every event consumer (sub-agent forwarding, AG-UI, tracing). Handing a password/API key through this path therefore persists it in state and streams it in events. Either drop secret from the enum until the framework can redact it, or have the resume path resolve the question type from target.getInput() (the tool call still carries the questions payload) and write a masked placeholder (e.g. q_1: ***) for secret answers while AskUserResult.toString() also stops printing raw answers.

throw new IllegalArgumentException("AskUserResult.toolCallId must not be empty");
}
this.toolCallId = toolCallId;
this.answers = answers != null ? java.util.Map.copyOf(answers) : java.util.Map.of();

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.

Map.copyOf has two behaviours that fight the rest of this class: (1) it throws NullPointerException for null values, while formatAnswerValue explicitly supports null (renders "(no answer)") and the javadoc describes skipped/unanswered questions — so a legitimate new AskUserResult(id, Map-of-q1-to-null) fails in the constructor with a stack trace instead of being formatted; (2) it does not preserve iteration order, so for multi-question calls the model-visible text (q_1: ...\nq_2: ...) comes out in an arbitrary order per construction, which is a needless source of non-deterministic prompts (and brittle assertions in tests). Collections.unmodifiableMap(new LinkedHashMap<>(answers)) (with nulls either filtered or tolerated consistently) fixes both.

// correlation, then signal stop via RequestStopEvent. The agent's
// acting() will set GenerateReason to ASK_USER_ASKING and return;
// the tool is never executed.
if (!pendingAskUser.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.

This early return runs before the permission-HITL branch, so a batch that contains both an ask_user call and calls gated by ASK/deny rules loses information: RequireUserConfirmEvent is never emitted and writeAutoDeniedResults(toolCalls, autoDenied) is skipped, even though updateToolCallStates already marked those calls ASKING above. The host only sees RequireUserAskEvent, so it is never told about the confirmations it will have to answer afterwards (and the denied calls keep a dangling tool_use with no tool_result until a later round). Please either surface both pauses in one round (emit RequireUserConfirmEvent alongside RequireUserAskEvent, and write the auto-denied results before returning) or explicitly reject/document mixed batches.

// on the assistant message when the interrupt was emitted; a resume payload that
// carries AskUserResults is honored regardless of that metadata.
List<ToolUseBlock> asking = askingToolCalls();
if (isAskUserPaused() || hasAskUserResults(msgs)) {

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.

isAskUserPaused() || hasAskUserResults(msgs) is evaluated before the asking.isEmpty() confirmation branch, and asking mixes permission-ASK calls with ask_user calls (both are ToolCallState.ASKING). Two consequences: (a) after a mixed pause, a caller that legitimately supplies ConfirmResults gets an IllegalStateException from validateAndAcceptAskUserResults demanding AskUserResults — the run cannot be resumed with confirmations until the questions are answered, which is the reverse of the order the host was told about in the previous finding; (b) an AskUserResult whose id belongs to a permission-ASK call is accepted (its id is in expectedIds), so a confirmation can be consumed as an "answer" and the tool never runs. Consider keying the pending state by pause kind (e.g. persist the ask_user tool-call ids next to the reply id) and validating answer ids against that set, so each resume path only accepts its own payload.

* The caller resumes by issuing a second {@code agent.call(...)} whose message carries
* {@code List<AskUserResult>} under {@code Msg.METADATA_ASK_USER_RESULTS}.
*/
ASK_USER_ASKING,

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.

Adding a pause reason to core is a cross-module change, but the existing special-cases of PERMISSION_ASKING were not updated for ASK_USER_ASKING, so those consumers will treat an ask-user pause as an ordinary turn completion: agentscope-service/.../managed/SessionTurnRunner.java (isCorePermissionAsking, ~line 767 — the data plane will finish the turn and never surface the pending questions), agentscope-extensions-aistio/.../HarnessAgentTaskStarter.java:365 (auto-approval loop exits with the question unanswered), and agentscope-extensions-agent-protocol/.../AgentProtocolTaskStore.java:268. Same for the AG-UI PermissionConfirmEventConverter / SubagentEventConverter and RemoteEventCodec.toRemoteEvent, which have no branch for RequireUserAskEvent/UserAskResultEvent (they fall back to the generic AGENT_EVENT payload, which older peers cannot decode since the two new @JsonSubTypes names are unknown to them). Please state in the PR description which bridges are intentionally out of scope for this follow-up of #2865, and prefer a default-branch/Unknown handling for the wire compatibility.

+ ". Expected: "
+ expectedIds);
}
ToolUseBlock target = questionToolCall(asking, toolCallId);

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.

Nit: unlike applyConfirmResults, this path never moves the answered ToolUseBlock out of ToolCallState.ASKING (no updateToolCallStates(...) / block replacement), so the persisted assistant message keeps saying "asking" for a call that already has a result. Nothing depends on it today, but it makes the context harder to reason about after a state reload and inconsistent with the permission path. Setting the answered calls to ALLOWED (or FINISHED) right after writing the answer message would keep both paths symmetrical.

ALLOW("allow"),
DENY("deny"),
ASK("ask"),
ASK_USER("ask_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 javadoc says ASK_USER "is not registerable as a rule", but nothing enforces it: PermissionEngine.addRule()'s switch (lines ~96-105) has cases for ALLOW/DENY/ASK/PASSTHROUGH and no default, so an ASK_USER rule is accepted, stored nowhere and silently ignored — the worst failure mode for a permission config. Adding case ASK_USER -> throw new IllegalArgumentException(...) (and the same in PermissionRule's compact constructor) would make the contract real. Also worth a note in the changelog: PermissionBehavior.fromString("ask_user") and the two new AgentEvent subtypes are additive, so state/config written by a newer build is not readable by an older one.

* iteration reads the user's answers without executing the tool. The correlated
* {@link UserAskResultEvent} is emitted for streaming consumers.
*/
private void validateAndAcceptAskUserResults(List<Msg> msgs, List<ToolUseBlock> asking) {

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.

Minor style points in this change set: isInAny dereferences id.equals(...) where id is tc.getId(), while the surrounding code deliberately treats tool-call ids as possibly null (filter(Objects::nonNull) when building expectedIds) — Objects.equals(p.getId(), id) would be null-safe; extractAskUserResults keeps an @SuppressWarnings("unchecked") that no longer suppresses anything (the method uses pattern matching); AskUserResult and UserAskResultEvent use fully-qualified java.util.Map/java.util.List instead of imports, which differs from the rest of io.agentscope.core.event. And since this PR adds public API (PermissionBehavior.ASK_USER, two events, two Msg metadata keys, PermissionDecision.askUser), fix(harness): ... understates it — feat(core,harness): add ask_user HITL direction reads truer for the changelog.

@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-review of c448d95e ("harden ask_user HITL flow"). The new commit resolves most of my previous round: ASK_USER is now evaluated before the EXPLORE / ACCEPT_EDITS read-only shortcut (with tests for both modes), AskUserResult keeps insertion order and tolerates null answers instead of Map.copyOf, answered calls are moved out of ASKING into FINISHED, auto-denied results are written before the ask-user pause, and secret answers are now masked in both the model-visible tool result and UserAskResultEvent. Thanks — that was the right shape of fix.

What keeps this at COMMENT rather than approve: the redaction added here is fail-open against answer-key mismatch (the leak class it targets), the pause-kind detection now depends entirely on persisted metadata that a reloaded or compacted session may not carry (a legitimate resume can become unrecoverable), and the mixed-batch case aborts the run instead of degrading. Separately, one item from the previous round is still open and out of this diff's reach:

  • Cross-module GenerateReason.ASK_USER_ASKING consumers still not updated. At this HEAD, agentscope-service/service-dataplane/.../managed/SessionTurnRunner.java:767 still tests only == GenerateReason.PERMISSION_ASKING, and neither agentscope-extensions-aistio/.../HarnessAgentTaskStarter.java nor agentscope-extensions-agent-protocol/.../AgentProtocolTaskStore.java mentions ASK_USER at all, so a managed/aistio/agent-protocol run that pauses to ask a question is reported as an ordinary completed turn and the questions are never surfaced to the host. RemoteEventCodecPassthroughTest does show the two new events passing through the sub-agent codec, so that part is covered. If the service and extension paths are intentionally out of scope for this PR, please say so in the description and file a follow-up issue so the gap is tracked rather than silent.

Findings

See the inline comments: 3 warnings (ReActAgent.java:2062 fail-open secret redaction, ReActAgent.java:1782 pause kind derived only from reply-id metadata, ReActAgent.java:3426 mixed-batch hard abort) and 2 infos (PermissionEngine.java:225 mode-shortcut ordering side effects, AskUserResult.java:114 redaction is indistinguishable from a literal [REDACTED] answer).

Suggestions

  1. For a tool call that declares any type: "secret" question, mask every answer key that is not a declared non-secret id, and log a warning when a declared secret id matched no answer key.
  2. Derive the pause kind from the pending ASKING tool calls (name resolves to a tool whose self-check returned ASK_USER) and keep the reply-id metadata as correlation only, so a state reload or compaction that loses the metadata cannot strand a session.
  3. Degrade on a mixed ASK + ASK_USER batch instead of failing the turn: keep one pause pending, or synthesise an error tool result for the offending subset so the model can re-issue it.
  4. Worth one line in both new docs sections: DONT_ASK does not auto-decline an ASK_USER pause.

Notes

CI on this HEAD: build (ubuntu-latest), build (windows-latest), validate, Check License, Check Module Sync, codecov/patch all pass, license/cla is signed, merge state is MERGEABLE/BLOCKED. Tests added are proportionate and cover the redaction, ordering, and mode-exhaustiveness paths, so the remaining items above are hardening/robustness rather than regressions.


Automated review by github-manager-bot

}

/** Returns the ids of secret questions in one ask_user tool call. */
private Set<String> secretQuestionIds(ToolUseBlock toolCall) {

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] secret redaction is fail-open when the host keys answers differently. formatAnswers / redactedFor mask only the entries whose key is literally one of the declared secret question ids, so a host that answers with the question text (or a per-question id it invented because the model omitted id) writes the raw API key / password into the model-visible ToolResultBlock, which is then persisted into the session state store — exactly the leak this hardening commit set out to close, and the failure is silent. Please invert the policy for a tool call that contains at least one type: "secret" question: start from the declared ids and mask everything that is not a declared non-secret id (whitelist instead of blacklist), so an unknown key is redacted rather than rendered verbatim. A cheap companion guard is to log at WARN when secretIds is non-empty but none of them matched an answer key, so hosts can discover the mismatch instead of leaking.

}
if (hasAskUserResults(msgs)) {
throw new IllegalStateException(
"AskUserResult was supplied, but no ASK_USER pause is pending. "

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] The pause kind is now inferred solely from persisted reply-id metadata, which can drop a legitimate resume. isAskUserPaused() returns !resolvePendingRequestReplyId(METADATA_ASK_REQUEST_REPLY_ID).isEmpty(), and that helper reads the metadata of findLastAssistantMsg(). If that metadata is absent — state written before this build, a session reloaded through AgentStateStore, or a harness context-compaction pass that replaced/trimmed the last assistant message — a host that correctly sends List<AskUserResult> after a RequireUserAskEvent now hits this IllegalStateException and can never resume the run, while the ASKING ask_user calls stay pending forever. The previous isAskUserPaused() || hasAskUserResults(msgs) was loose but never stranded a session. Suggest deriving the pause kind from the pending blocks themselves (asking.stream().anyMatch(t -> "ask_user".equals(t.getName()) && secret-ish shape) — i.e. treat a call whose name resolves to a tool that returned ASK_USER from checkPermissions as an ASK_USER pause) and keeping the reply-id metadata only as a correlation hint, or at minimum auto-healing the missing metadata when every pending ASKING id belongs to an ask_user call.

return new PermissionGate(pending, denied);
if (!pending.isEmpty() && !pendingAskUser.isEmpty()) {
throw new IllegalStateException(
"A single model tool batch cannot mix permission "

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] Failing the entire call for a model-controlled batch shape. The mixing check runs inside evaluatePermissions(...).map(...), so the exception becomes an onError on the whole turn: no RequireUserAskEvent, no RequireUserConfirmEvent, no denied results written, and updateToolCallStates(...) in actingStream never runs — the host just sees a failed call for something the model chose to emit. Batching is model-controlled, so this path is reachable in normal use of ask_user next to a gated tool, and DONT_ASK/BYPASS hosts have no way to clear it. Documenting the constraint in the PermissionGate javadoc is good, but please also degrade instead of aborting: e.g. keep the ASK calls pending for the next round and emit RequireUserAskEvent for the ASK_USER ones (or synthesise an error ToolResultBlock for the offending subset so the model can re-issue them separately). Either way the run should stay resumable.

if (decision.getBehavior() == PermissionBehavior.ASK_USER) {
return Mono.just(decision);
}
PermissionDecision modeDecision = checkExploreMode(tool);

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 mode shortcut now always awaits the tool self-check. Correct fix for the EXPLORE / ACCEPT_EDITS bypass, and the new PermissionEngineTest cases cover both directions. Two consequences worth a javadoc note: (1) a tool's checkPermissions() is now invoked on every call in read-only modes even though checkExploreMode will usually override its answer, so self-checks that are async or side-effecting (remote policy lookups, counters, audit logs) now run in EXPLORE; (2) in ACCEPT_EDITS a read-only tool's own DENY is still overridden to ALLOW by the mode decision because the mode is consulted after the tool. Both are pre-existing semantics, just now on a path that is easier to change — happy to see them called out in the method comment so the next reader does not have to re-derive the ordering.

* @param secretQuestionIds question ids whose values must be redacted
* @return this result when no redaction is needed, otherwise a redacted copy
*/
public AskUserResult redactedFor(Set<String> secretQuestionIds) {

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] Consider exposing which answers were redacted. redactedFor replaces secret values with the literal "[REDACTED]", so a UI or audit consumer of UserAskResultEvent cannot distinguish "the user typed [REDACTED]" from "this value was masked", and it loses the count of answered questions. Adding something like getRedactedQuestionIds() (or a Map<String,Object> answers, Set<String> redacted pair) on the event side keeps the value out of the stream while letting hosts render answered (hidden) correctly. Also worth stating in the docs/v1 + docs/v2 sections added here that ASK_USER pauses are not auto-declined by DONT_ASK, since that mode is what unattended hosts reach for — the docs currently only say "do not register the tool".

@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-review of c415cd30 ("harden ask_user pause handling"). All three blocking items from my previous round are addressed, and addressed well:

  • Fail-closed secret redaction. formatAnswers(answers, nonSecretQuestionIds, hasSecretQuestion) / redactedFor(...) now mask every key that is not a declared non-secret id whenever the tool call declared a secret question, and warnIfSecretAnswersDoNotMatch logs when a declared secret id matched nothing. The new secretAnswerWithMismatchedKeyIsRedactedFromModelContextAndEvents and failClosedRedactionMasksUnknownAnswerKeysWhenSecretQuestionExists tests pin exactly the leak class I described.
  • Pause kind no longer depends on reply-id metadata alone. classifyAskingToolCalls derives it from the per-tool-call PermissionBehavior now persisted via updateToolCallStates(..., behaviorUpdates), with the built-in ask_user input shape and a re-check through evaluateOne as recovery for states written before the marker existed. resumeRecoversWhenAskUserReplyMetadataIsLost and resumeRecoversCustomAskUserWhenAllPauseMetadataIsLost cover both. That removes the "stranded session" failure mode.
  • Mixed ASK + ASK_USER batches degrade instead of aborting. The IllegalStateException in evaluatePermissions is gone and both pause kinds are surfaced, which is the behaviour I asked for. One consequence of the new branch still needs a decision — see the inline [Warning] on the stop reason.

Why this is still COMMENT rather than approve

build (ubuntu-latest) is failing on this HEAD and build (windows-latest) was cancelled with it:

io.agentscope.harness.agent.AguiPermissionResumeTest.approvedToolResultSurvivesNewStreamContext(boolean, boolean)[2]
  RunError[threadId=resume-thread, runId=second,
    message=Thread already has an active run; wait for run first to finish
             before starting another run on the same thread,
    code=AGUI_INTERRUPT_CONTRACT_ERROR] ==> expected: <true> but was: <false>
Tests run: 1045, Failures: 1, Errors: 0, Skipped: 9

The failing assertion is the AG-UI active-run marker race that #3109 fixes: the rejected run is the officialResume variant, and the message (Thread already has an active run; wait for run first to finish) is exactly what #3109's doOnComplete/doOnError change removes. Nothing in this diff touches AguiRequestProcessor, and this branch does not contain that fix. So this reads as the pre-existing intermittent race rather than a regression you introduced — but #3109 is still open, so it will keep being red until one of them lands. Two options, either is fine:

  1. rebase once #3109 lands, or
  2. if a maintainer would rather not couple the two PRs, note the known-flake link in the description (and re-run once) so the red check is not read as a regression in this PR. If a re-run fails on the same variant, that stops being the flake story and I'd want to look again.

Findings

See the inline comments: 1 warning (ReActAgent.java:3263-3268 — a mixed pause reports only ASK_USER_ASKING, so the confirmation half is invisible to the PERMISSION_ASKING consumers) and 2 infos (AskUserResult.java:91 — the retained fail-open overload, ReActAgent.java:4382 — framework key inside provider-facing ToolUseBlock.metadata).

The cross-module GenerateReason consumers I raised last round are still unaddressed (SessionTurnRunner.java:767, HarnessAgentTaskStarter.java:365, AgentProtocolTaskStore.java:268); the mixed-pause branch above makes that gap reachable from a new state, so it is now worth resolving here rather than in a follow-up.

Suggestions

  1. Derive the stop reason from the set of outstanding pause kinds (or emit PERMISSION_ASKING when pending is non-empty) and update the three consumers, or add an explicit combined reason.
  2. Consider @Deprecated on the two-argument formatAnswers/redactedFor overloads so hosts cannot silently keep the fail-open behaviour.
  3. Worth one sentence in the docs sections added here: DONT_ASK does not auto-decline an ASK_USER pause, and a mixed batch now pauses for both kinds.

Automated review by github-manager-bot

Comment on lines +3263 to +3268
return Flux.<AgentEvent>just(
new RequireUserAskEvent(replyId, pendingAskUser),
new RequireUserConfirmEvent(replyId, pending),
new RequestStopEvent(
"ask user and permission confirmation",
GenerateReason.ASK_USER_ASKING));

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 mixed pause is reported as ASK_USER_ASKING only, so the confirmation half is invisible to the PERMISSION_ASKING consumers. This branch emits both RequireUserAskEvent and RequireUserConfirmEvent but stops with GenerateReason.ASK_USER_ASKING; the three places that special-case PERMISSION_ASKING will not see a permission pause at all:

  • agentscope-service/service-dataplane/.../managed/SessionTurnRunner.java:767 (isCorePermissionAsking → durable confirmation not persisted, and the guard at line 611 never fires)
  • agentscope-extensions/agentscope-extensions-aistio/.../adapter/HarnessAgentTaskStarter.java:365
  • agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agent-protocol/.../AgentProtocolTaskStore.java:268

Before this commit a mixed batch could not exist (it threw), so this is a new state on the wire rather than a pre-existing gap: a managed/aistio/agent-protocol run that pauses for both will surface the questions, drop the pending confirmation, and the caller has no reason code telling it a second pause kind is outstanding. Either pick the reason from the set of outstanding pause kinds (or add PERMISSION_ASKING when pending is non-empty), or add an explicit combined reason and update those three consumers in this PR so the gap is not silent.

This is the same cross-module item I flagged last round; the GenerateReason.ASK_USER_ASKING javadoc still only documents the question case.

* @param secretQuestionIds question ids whose values must not be exposed
* @return a stable, human-readable rendering of the answers with secret values redacted
*/
public static String formatAnswers(Map<String, Object> answers, Set<String> secretQuestionIds) {

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 fail-closed overload is the right fix, and the new failClosedRedactionMasksUnknownAnswerKeysWhenSecretQuestionExists test pins it. One follow-up: this two-argument form is still public and keeps the old fail-open semantics (Set = ids to mask), so a host that migrates by accident gets the weak behaviour with no signal. Consider @Deprecated on it (pointing at the three-argument form) or documenting the parameter name difference loudly — the two overloads take the same Set<String> type with opposite meaning, which is easy to pass backwards at a call site.

} else {
Map<String, Object> metadata = new HashMap<>(t.getMetadata());
metadata.put(
ToolUseBlock.METADATA_PERMISSION_BEHAVIOR, behavior.name());

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] Nice — persisting the resolved behavior per tool call is what makes the pause kind survive compaction/reload instead of depending on reply-id metadata, and it removes the failure mode I raised last round. Two small notes on putting a framework key into ToolUseBlock.metadata:

  1. The field was previously documented as provider-specific, and provider converters walk it (Gemini thoughtSignature, OpenAI reasoningDetail). Nothing bulk-copies it into an outbound payload today, so this is safe now, but the javadoc rename to "provider/framework-specific" is doing real load-bearing work — worth calling out in the PR description so future formatters do not forward unknown keys verbatim.
  2. The key lands in persisted AgentState / session JSON, so it is effectively wire-visible state going forward. permissionBehavior() already tolerates a garbage value (unknown string → recovery path), which is the right posture for old-vs-new state.

@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

The new commit 949db2e5 ("fix(core,adapters): preserve mixed HITL pauses") introduces GenerateReason.PERMISSION_AND_ASK_USER_ASKING to represent model responses containing both ask_user and permission-gated tool calls. The fix correctly updates all consumers across core, harness, aistio, agent-protocol, and service modules. The previously reported "mixed pause reported as ASK_USER_ASKING only, confirmation half invisible" issue is now resolved.

CI

Build (ubuntu-latest) FAILS: HarnessAgentAskUserTest.askUserPausesEvenUnderBypassMode errors with JUnitException: Failed to close extension context, root cause IOException: Failed to delete temp directory ... s-bypass (DirectoryNotEmptyException).

Root cause: PR-owned test code. HarnessAgentAskUserTest wires .workspace(path) to the JUnit @TempDir but has no @AfterEach and never closes the created agents, so session dirs (e.g. s-bypass/) still hold transcript/mirror files and background executors may still be I/O-active when JUnit deletes the temp dir. This is not the upstream @TempDir flake referenced in a0d565a/75c73811 — the failing class is introduced by this PR.

Fix: Add @AfterEach void closeAgent() { agent.close(); } or call .disableTranscript() in the builder at line 109.

Findings

  • HarnessAgentAskUserTest.java:109 (critical) — New test never closes the agents it creates; JUnit @TempDir teardown fails (Failed to delete temp directory ... s-bypass), which is what reds build (ubuntu-latest).
  • AgentProtocolTaskStore.java:269 (warning) — Mixed pause (PERMISSION_AND_ASK_USER_ASKING) silently drops the ask_user subset. The adapter handles only permission confirmations and discards ask_user calls, violating the documented contract that adapters "must preserve the other subset."
  • SessionTurnRunner.java:764 (info) — isCorePermissionAsking now returns true for mixed pauses, triggering an exception with a misleading message ("PERMISSION_ASKING" instead of "PERMISSION_AND_ASK_USER_ASKING").
  • GenerateReason.java:82 (info) — New enum value breaks backward compatibility for strict deserializers. Ensure lenient enum parsing in all event consumers.

Automated review by github-manager-bot

.stateStore(new InMemoryAgentStateStore())
.disableCompaction()
.disableMemoryHooks()
.enableAskUser()

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.

CI failure (build ubuntu-latest): the new test never releases the agents it creates, breaking @TempDir teardown.

The test class has no @AfterEach and no agent.close() call anywhere (verified: grep finds zero), while build(...) wires .workspace(...) to the JUnit @TempDir. After the run, session directories (e.g. s-bypass/) still contain transcript/mirror files and background executors (session mirror / maintenance scheduler) may still be alive, so JUnit fails with IOException: Failed to delete temp directory ... <root>, s-bypass -> JUnitException: Failed to close extension context (see the failed build on this head).

Fix: keep the created agents in fields and close() them in @AfterEach (or close each agent in a try/finally per test), so mirror I/O is quiesced before the temp dir is deleted. This looks PR-owned rather than the upstream @TempDir flake referenced in a0d565a/75c73811: the failing class and its workspace layout are introduced by this PR.

Msg reply = resultRef.get();
if (reply != null
&& reply.getGenerateReason() == GenerateReason.PERMISSION_ASKING
&& isPermissionApprovalPause(reply.getGenerateReason())

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.

Mixed pause silently drops the ask_user subset.

When the agent returns GenerateReason.PERMISSION_AND_ASK_USER_ASKING, this adapter handles only the permission confirmation calls (via extractPendingConfirms at line 271) and sends a REQUIRE_CONFIRM event. The ask_user subset is never surfaced to the client and is lost when the run resumes with only ConfirmResult metadata. The PR docs state "adapters that support only one pause kind must preserve the other subset rather than treating the turn as complete," but this adapter discards the ask_user calls.

Impact: When permission confirmations are received and the agent resumes, the model will see incomplete state (missing ask_user results). Consider throwing IllegalStateException for mixed pauses (like pure ASK_USER pauses at line 286-289), or persisting the ask_user pending calls alongside permission confirmations.

@@ -764,7 +764,9 @@ private void persistRemainingThinking(
static boolean isCorePermissionAsking(AgentEvent event) {

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.

Error message is misleading for mixed pauses.

isCorePermissionAsking now returns true for PERMISSION_AND_ASK_USER_ASKING, which triggers the CorePermissionConfirmationException at line 610. The exception message says "Core PermissionEngine returned PERMISSION_ASKING..." but for mixed pauses the actual reason is PERMISSION_AND_ASK_USER_ASKING. The message should reflect the actual reason to aid debugging.

Fix: Capture the actual GenerateReason from the AgentResultEvent and include it in the exception message at line 611.

* {@code AskUserResult}s for question calls. Adapters that only support one pause kind must
* preserve the other subset instead of treating this reason as a completed turn.
*/
PERMISSION_AND_ASK_USER_ASKING,

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.

Backward compatibility: new enum value breaks old deserializers.

Adding PERMISSION_AND_ASK_USER_ASKING to the GenerateReason enum is a breaking change for serialized events. Old clients that deserialize this value using GenerateReason.valueOf(String) will throw IllegalArgumentException. While most JSON libraries handle unknown enum values gracefully (returning null or a default), clients that use strict enum parsing will fail.

Mitigation: Ensure all event consumers use lenient enum parsing (e.g., @JsonEnumDefaultValue or try-catch around valueOf). Document this in the release notes.

@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

Commit 54dcaaa1 ("address ask_user review follow-ups") resolves every issue from the previous review: the HarnessAgentAskUserTest agents are now registered and closed in @AfterEach (the ubuntu build that was red on Failed to delete temp directory now passes), AgentProtocolTaskStore refuses a mixed pause with an explicit error instead of silently dropping the ask_user subset, the SessionTurnRunner exception message reports the actual reason, and the new lenient @JsonCreator closes the strict-deserializer compatibility gap with a test. Remaining note is a non-blocking observability suggestion below.

CI

build (ubuntu-latest) passes; build (windows-latest) still pending at review time.


Automated review by github-manager-bot

* should preserve the raw wire payload or upgrade before handling the turn.
*/
@JsonCreator
public static GenerateReason fromJson(String value) {

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 silent MODEL_STOP fallback is a reasonable wire-compatibility choice, but for a pause reason it makes an unfinished turn look finished to an older reader. Consider at least a LOG.warn (or a one-line comment pointing to a metric) in the IllegalArgumentException branch so a fallback that swallows a pending HITL/ask_user pause is observable in production rather than only in the javadoc.

@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-review after 40a01b62. The commit itself is a two-line comment on the GenerateReason.fromJson fallback — the code path is unchanged, so my previous review of 54dcaaa1 still stands. One blocking-ish item though: CI is red on this head (build (windows-latest) failed, build (ubuntu-latest) was cancelled as a consequence), and it failed in AguiPermissionResumeTest, which passes on main. Since the only delta since the green 54dcaaa1 run is a comment, this needs either a re-run or a real fix before this can be merged — I have downgraded my previous approval to a comment so the green checkmark does not outlive the red CI.

Findings

  • [Warning] agentscope-core/src/main/java/io/agentscope/core/message/GenerateReason.java:125 — the comment documents a risk that the code still swallows; consider making the fallback observable.

Next step

Re-run Java CI with Maven on 40a01b62. If AguiPermissionResumeTest.approvedToolResultSurvivesNewStreamContext fails again, the Thread already has an active run error is a genuine interaction with #3100 (already in main) rather than a flake, and the mixed HITL pause bookkeeping in this PR is the first place I would look.


Automated review by github-manager-bot

return valueOf(value);
} catch (IllegalArgumentException ignored) {
// Keep this fallback observable via metrics/logging; it may hide a pending
// HITL/ASK_USER pause.

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 comment now documents the risk, but the silent MODEL_STOP fallback is still there: an unknown/typo'd reason string on the wire (for example a serialized ASK_USER pause produced by an older peer) is downgraded to a normal model stop, so a pending HITL pause is dropped instead of surfaced. Since the fallback is already annotated as potentially hiding an ASK_USER pause, could this commit also emit a log.warn (or a counter) with the rejected value? A code comment alone leaves the failure mode invisible in production.

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.

[Feature]: Built-in ask_user tool - model-initiated questions to the user (HITL ask direction)

3 participants