Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 145 additions & 30 deletions agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
import io.agentscope.core.event.ToolResultEndEvent;
import io.agentscope.core.event.ToolResultStartEvent;
import io.agentscope.core.event.ToolResultTextDeltaEvent;
import io.agentscope.core.event.UserConfirmResultEvent;
import io.agentscope.core.formatter.JsonSchema;
import io.agentscope.core.formatter.ResponseFormat;
import io.agentscope.core.hook.Hook;
Expand Down Expand Up @@ -1537,36 +1538,13 @@ private Mono<Msg> doCallInner(List<Msg> msgs) {
// ConfirmResults (via Msg.METADATA_CONFIRM_RESULTS) before we can proceed.
List<ToolUseBlock> asking = askingToolCalls();
if (!asking.isEmpty()) {
List<ConfirmResult> confirmResults = extractConfirmResults(msgs);
if (confirmResults.isEmpty()) {
String pendingSummary =
asking.stream()
.map(t -> t.getName() + " (id=" + t.getId() + ")")
.collect(Collectors.joining(", "));
throw new IllegalStateException(
"Agent is paused for human-in-the-loop confirmation: the following"
+ " tool call(s) are in ASKING state and need your approval"
+ " before the agent can continue: ["
+ pendingSummary
+ "]. This call supplied no confirmation, so it cannot"
+ " proceed.\n"
+ "To resume, send a follow-up message that carries a"
+ " List<ConfirmResult> under the metadata key \""
+ Msg.METADATA_CONFIRM_RESULTS
+ "\", e.g.:\n"
+ " UserMessage.builder()\n"
+ " .metadata(Map.of(Msg.METADATA_CONFIRM_RESULTS,\n"
+ " List.of(new ConfirmResult(true, toolCall))))\n"
+ " .build();\n"
+ "Tip: capture the ToolUseBlocks from the"
+ " RequireUserConfirmEvent emitted when the agent paused.\n"
+ "If you did NOT expect a pending confirmation here, a"
+ " previous run most likely paused on one of these tool calls"
+ " and persisted that state under the same (agentId,"
+ " sessionId); start a fresh session, clear the persisted"
+ " state, or use an in-memory state store to begin clean.");
}
applyConfirmResults(confirmResults);
List<ConfirmResult> normalizedResults =
validateAndNormalizeConfirmResults(msgs, asking);
publishEvent(
new UserConfirmResultEvent(
resolvePendingConfirmRequestReplyId(), normalizedResults));
applyConfirmResults(normalizedResults);
clearPendingConfirmRequest();
return resumeAgent();
}

Expand Down Expand Up @@ -1621,6 +1599,142 @@ private List<ConfirmResult> extractConfirmResults(List<Msg> msgs) {
return collected;
}

/**
* Validate the user-provided confirmation payload against the currently ASKING tool calls.
*
* <p>Permission HITL resumes with one or more confirmations for currently ASKING tool
* calls. Confirmations may cover a subset of ASKING calls, but no result may reference a
* stale or unrelated tool call. Returning a copied list gives downstream event emission and
* state mutation the same trusted payload.
*/
private List<ConfirmResult> validateAndNormalizeConfirmResults(
List<Msg> msgs, List<ToolUseBlock> asking) {
List<ConfirmResult> results = extractConfirmResults(msgs);
if (results.isEmpty()) {
String pendingSummary =
asking.stream()
.map(t -> t.getName() + " (id=" + t.getId() + ")")
.collect(Collectors.joining(", "));
throw new IllegalStateException(
"Agent is paused for human-in-the-loop confirmation: the following"
+ " tool call(s) are in ASKING state and need your approval"
+ " before the agent can continue: ["
+ pendingSummary
+ "]. This call supplied no confirmation, so it cannot"
+ " proceed.\n"
+ "To resume, send a follow-up message that carries a"
+ " List<ConfirmResult> under the metadata key \""
+ Msg.METADATA_CONFIRM_RESULTS
+ "\", e.g.:\n"
+ " UserMessage.builder()\n"
+ " .metadata(Map.of(Msg.METADATA_CONFIRM_RESULTS,\n"
+ " List.of(new ConfirmResult(true, toolCall))))\n"
+ " .build();\n"
+ "Tip: capture the ToolUseBlocks from the"
+ " RequireUserConfirmEvent emitted when the agent paused.\n"
+ "If you did NOT expect a pending confirmation here, a"
+ " previous run most likely paused on one of these tool calls"
+ " and persisted that state under the same (agentId,"
+ " sessionId); start a fresh session, clear the persisted"
+ " state, or use an in-memory state store to begin clean.");
}

Set<String> expectedIds =
asking.stream()
.map(ToolUseBlock::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));

Set<String> providedIds = new LinkedHashSet<>();
List<ConfirmResult> normalized = new ArrayList<>();

for (ConfirmResult result : results) {
if (result == null || result.getToolCall() == null) {
throw new IllegalStateException(
"ConfirmResult and ConfirmResult.toolCall must not be null.");
}
ToolUseBlock toolCall = result.getToolCall();
String toolCallId = toolCall.getId();
if (toolCallId == null || toolCallId.isEmpty()) {
throw new IllegalStateException("ConfirmResult.toolCall.id must not be empty.");
}
if (!providedIds.add(toolCallId)) {
throw new IllegalStateException(
"Duplicate ConfirmResult for tool call ID: " + toolCallId);
}
if (!expectedIds.contains(toolCallId)) {
throw new IllegalStateException(
"ConfirmResult references non-ASKING tool call ID: "
+ toolCallId
+ ". Expected: "
+ expectedIds);
}
normalized.add(result);
}
return List.copyOf(normalized);
}

/**
* Resolve the reply id from the assistant message that originally paused for confirmation.
*
* <p>This keeps {@link UserConfirmResultEvent} correlated with the prior
* {@link RequireUserConfirmEvent}, even though the confirmation arrives in a later
* {@code agent.call(...)} invocation.
*/
private String resolvePendingConfirmRequestReplyId() {
Msg confirmRequestMsg = findLastAssistantMsg();
if (confirmRequestMsg == null || confirmRequestMsg.getMetadata() == null) {
return "";
}
Object raw = confirmRequestMsg.getMetadata().get(Msg.METADATA_CONFIRM_REQUEST_REPLY_ID);
return raw instanceof String s ? s : "";
}

/**
* Persist the reply id for the pending confirmation request on the live assistant message.
*
* <p>The assistant message already owns the ASKING {@link ToolUseBlock}s, so storing the
* correlation metadata there lets the next call recover it from session state.
*/
private void persistPendingConfirmRequest(String replyId) {
Msg lastAssistant = findLastAssistantMsg();
if (lastAssistant == null) {
return;
}
Map<String, Object> metadata = new HashMap<>(lastAssistant.getMetadata());
metadata.put(Msg.METADATA_CONFIRM_REQUEST_REPLY_ID, replyId);
replaceLastAssistantMsg(lastAssistant.withMetadata(metadata));
}

/**
* Remove confirmation-request correlation metadata after the resume payload is accepted.
*
* <p>Leaving it behind would make later agent turns appear to belong to an already-closed
* HITL request.
*/
private void clearPendingConfirmRequest() {
Msg lastAssistant = findLastAssistantMsg();
if (lastAssistant == null || lastAssistant.getMetadata() == null) {
return;
}
if (!lastAssistant.getMetadata().containsKey(Msg.METADATA_CONFIRM_REQUEST_REPLY_ID)) {
return;
}
Map<String, Object> metadata = new HashMap<>(lastAssistant.getMetadata());
metadata.remove(Msg.METADATA_CONFIRM_REQUEST_REPLY_ID);
replaceLastAssistantMsg(lastAssistant.withMetadata(metadata));
}

private void replaceLastAssistantMsg(Msg replacement) {
List<Msg> ctx = state.contextMutable();
for (int i = ctx.size() - 1; i >= 0; i--) {
if (ctx.get(i).getRole() == MsgRole.ASSISTANT) {
ctx.set(i, replacement);
return;
}
}
}

/**
* Apply user confirmation results to the ASKING tool calls in context.
*
Expand Down Expand Up @@ -2507,6 +2621,7 @@ Flux<AgentEvent> actingStream(
// completion;
// initialise it to empty since no successful execution happened.
resultHolder.set(List.of());
persistPendingConfirmRequest(replyId);
return Flux.<AgentEvent>just(
new RequireUserConfirmEvent(replyId, pending),
new RequestStopEvent(
Expand Down
24 changes: 24 additions & 0 deletions agentscope-core/src/main/java/io/agentscope/core/message/Msg.java
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ public class Msg implements State {
*/
public static final String METADATA_CONFIRM_RESULTS = "agentscope_confirm_results";

/**
* Metadata key storing the {@code replyId} of the {@code RequireUserConfirmEvent} that paused
* this assistant turn. Used to correlate the later {@code UserConfirmResultEvent}.
*/
public static final String METADATA_CONFIRM_REQUEST_REPLY_ID =
"agentscope_confirm_request_reply_id";

/**
* Metadata key (boolean) marking a message as <em>synthetic</em>: framework-injected rather
* than authored by the user, the model, or a tool. Synthetic messages (e.g. the per-turn todo
Expand Down Expand Up @@ -671,6 +678,23 @@ public Msg withContent(List<ContentBlock> newContent) {
this.usage);
}

/**
* Returns a copy of this message with the given metadata.
*
* @param newMetadata the replacement metadata
* @return a new Msg with identical content but replaced metadata
*/
public Msg withMetadata(Map<String, Object> newMetadata) {
return new Msg(
this.id,
this.name,
this.role,
this.content,
newMetadata,
this.timestamp,
this.usage);
}

public static class Builder {

protected String id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import io.agentscope.core.event.RequestStopEvent;
import io.agentscope.core.event.RequireUserConfirmEvent;
import io.agentscope.core.event.ToolResultEndEvent;
import io.agentscope.core.event.UserConfirmResultEvent;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.GenerateReason;
import io.agentscope.core.message.Msg;
Expand Down Expand Up @@ -110,6 +111,10 @@ private static ChatResponse toolUseResponse(String toolId, String toolName, Stri
.build();
}

private static ChatResponse toolUseResponse(List<ToolUseBlock> toolUses) {
return ChatResponse.builder().content(List.copyOf(toolUses)).build();
}

private static final class AskingTool extends ToolBase {
AskingTool(String name) {
super(name, "asks for permission", schemaFor(), false, true, false, null, false, false);
Expand Down Expand Up @@ -281,6 +286,77 @@ void askingToolEmitsRequireUserConfirmAndRequestStopEvents() {
assertEquals(GenerateReason.PERMISSION_ASKING, stop.getGenerateReason());
}

@Test
void confirmedResumeEmitsUserConfirmResultEventCorrelatedToRequireEvent() {
ChatModelBase model =
new ScriptedModel(
List.of(
() -> Flux.just(toolUseResponse("tc1", "ask", "x")),
() -> Flux.just(textResponse("done"))));
ReActAgent agent = buildAgent(model, toolkitWith(new AskingTool("ask")));

List<AgentEvent> pauseEvents = agent.streamEvents(List.of()).collectList().block();
assertNotNull(pauseEvents);
RequireUserConfirmEvent req =
(RequireUserConfirmEvent)
pauseEvents.get(indexOf(pauseEvents, RequireUserConfirmEvent.class));

List<AgentEvent> resumeEvents =
agent.streamEvents(List.of(confirmMsg(true, req.getToolCalls().get(0))))
.collectList()
.block();
assertNotNull(resumeEvents);

int iConfirm = indexOf(resumeEvents, UserConfirmResultEvent.class);
int iToolEnd = indexOf(resumeEvents, ToolResultEndEvent.class);
assertTrue(iConfirm >= 0, "UserConfirmResultEvent must be emitted on confirmed resume");
assertTrue(iToolEnd > iConfirm, "tool execution must follow the confirm-result event");

UserConfirmResultEvent confirm = (UserConfirmResultEvent) resumeEvents.get(iConfirm);
assertEquals(req.getReplyId(), confirm.getReplyId());
assertEquals(1, confirm.getConfirmResults().size());
assertEquals("tc1", confirm.getConfirmResults().get(0).getToolCall().getId());
}

@Test
void confirmResultsMayCoverSomeAskingToolCalls() {
ChatModelBase model =
new ScriptedModel(
List.of(
() ->
Flux.just(
toolUseResponse(
List.of(
ToolUseBlock.builder()
.id("tc1")
.name("ask1")
.input(Map.of("query", "x"))
.build(),
ToolUseBlock.builder()
.id("tc2")
.name("ask2")
.input(Map.of("query", "y"))
.build())))));
ReActAgent agent =
buildAgent(model, toolkitWith(new AskingTool("ask1"), new AskingTool("ask2")));

Msg first = agent.call(List.of()).block();
assertNotNull(first);
List<ToolUseBlock> pending = first.getContentBlocks(ToolUseBlock.class);
assertEquals(2, pending.size());

Msg resumed = agent.call(List.of(confirmMsg(true, pending.get(0)))).block();
assertNotNull(resumed);
assertEquals(GenerateReason.PERMISSION_ASKING, resumed.getGenerateReason());

List<ToolUseBlock> remaining =
resumed.getContentBlocks(ToolUseBlock.class).stream()
.filter(t -> t.getState() == ToolCallState.ASKING)
.toList();
assertEquals(1, remaining.size());
assertEquals("tc2", remaining.get(0).getId());
}

@Test
void askingToolResumeWithDeniedConfirmResultProducesDeniedToolResult() {
ChatModelBase model =
Expand Down
Loading