Skip to content

Commit 3fb4d0e

Browse files
authored
fix(core): emit ExternalExecutionResultEvent when external tool results resume (#2605)
1 parent 24124fb commit 3fb4d0e

9 files changed

Lines changed: 132 additions & 45 deletions

File tree

agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java

Lines changed: 41 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import io.agentscope.core.event.AllToolsDeniedEvent;
3434
import io.agentscope.core.event.ConfirmResult;
3535
import io.agentscope.core.event.ExceedMaxItersEvent;
36+
import io.agentscope.core.event.ExternalExecutionResultEvent;
3637
import io.agentscope.core.event.ModelCallEndEvent;
3738
import io.agentscope.core.event.ModelCallStartEvent;
3839
import io.agentscope.core.event.RequestStopEvent;
@@ -1720,12 +1721,7 @@ private Mono<Msg> doCallInner(List<Msg> msgs) {
17201721
// ConfirmResults (via Msg.METADATA_CONFIRM_RESULTS) before we can proceed.
17211722
List<ToolUseBlock> asking = askingToolCalls();
17221723
if (!asking.isEmpty()) {
1723-
List<ConfirmResult> confirmResults = extractAndValidateConfirmResults(msgs, asking);
1724-
publishEvent(
1725-
new UserConfirmResultEvent(
1726-
resolvePendingConfirmRequestReplyId(), confirmResults));
1727-
applyConfirmResults(confirmResults);
1728-
clearPendingConfirmRequest();
1724+
validateAndAcceptConfirmResults(msgs, asking);
17291725
return resumeAgent();
17301726
}
17311727

@@ -1793,15 +1789,15 @@ private List<ConfirmResult> extractConfirmResults(List<Msg> msgs) {
17931789
}
17941790

17951791
/**
1796-
* Validate the user-provided confirmation payload against the currently ASKING tool calls.
1792+
* Validate and accept a permission-HITL resume payload against the currently ASKING tool
1793+
* calls.
17971794
*
17981795
* <p>Permission HITL resumes with one or more confirmations for currently ASKING tool
17991796
* calls. Confirmations may cover a subset of ASKING calls, but no result may reference a
1800-
* stale or unrelated tool call. Returning a copied list gives downstream event emission and
1801-
* state mutation the same trusted payload.
1797+
* stale or unrelated tool call. Once accepted, the normalized results are applied to agent
1798+
* state and the correlated resume event is emitted.
18021799
*/
1803-
private List<ConfirmResult> extractAndValidateConfirmResults(
1804-
List<Msg> msgs, List<ToolUseBlock> asking) {
1800+
private void validateAndAcceptConfirmResults(List<Msg> msgs, List<ToolUseBlock> asking) {
18051801
List<ConfirmResult> results = extractConfirmResults(msgs);
18061802
if (results.isEmpty()) {
18071803
String pendingSummary =
@@ -1864,57 +1860,53 @@ private List<ConfirmResult> extractAndValidateConfirmResults(
18641860
}
18651861
normalized.add(result);
18661862
}
1867-
return normalized;
1863+
1864+
String replyId = resolvePendingRequestReplyId(Msg.METADATA_CONFIRM_REQUEST_REPLY_ID);
1865+
if (!replyId.isEmpty()) {
1866+
publishEvent(new UserConfirmResultEvent(replyId, normalized));
1867+
clearPendingRequestReplyId(Msg.METADATA_CONFIRM_REQUEST_REPLY_ID);
1868+
}
1869+
1870+
applyConfirmResults(normalized);
18681871
}
18691872

1870-
/**
1871-
* Resolve the reply id from the assistant message that originally paused for confirmation.
1872-
*
1873-
* <p>This keeps {@link UserConfirmResultEvent} correlated with the prior
1874-
* {@link RequireUserConfirmEvent}, even though the confirmation arrives in a later
1875-
* {@code agent.call(...)} invocation.
1876-
*/
1877-
private String resolvePendingConfirmRequestReplyId() {
1878-
Msg confirmRequestMsg = findLastAssistantMsg();
1879-
if (confirmRequestMsg == null || confirmRequestMsg.getMetadata() == null) {
1873+
/** Resolve the reply id for the pending HITL request stored on the last assistant message. */
1874+
private String resolvePendingRequestReplyId(String metadataKey) {
1875+
Msg requestMsg = findLastAssistantMsg();
1876+
if (requestMsg == null || requestMsg.getMetadata() == null) {
18801877
return "";
18811878
}
1882-
Object raw = confirmRequestMsg.getMetadata().get(Msg.METADATA_CONFIRM_REQUEST_REPLY_ID);
1879+
Object raw = requestMsg.getMetadata().get(metadataKey);
18831880
return raw instanceof String s ? s : "";
18841881
}
18851882

18861883
/**
1887-
* Persist the reply id for the pending confirmation request on the live assistant message.
1884+
* Persist the reply id for a pending HITL request on the live assistant message.
18881885
*
1889-
* <p>The assistant message already owns the ASKING {@link ToolUseBlock}s, so storing the
1890-
* correlation metadata there lets the next call recover it from session state.
1886+
* <p>The assistant message owns the paused {@link ToolUseBlock}s, so storing the correlation
1887+
* metadata there lets the next call recover it from session state.
18911888
*/
1892-
private void persistPendingConfirmRequest(String replyId) {
1889+
private void persistPendingRequestReplyId(String metadataKey, String replyId) {
18931890
Msg lastAssistant = findLastAssistantMsg();
18941891
if (lastAssistant == null) {
18951892
return;
18961893
}
18971894
Map<String, Object> metadata = new HashMap<>(lastAssistant.getMetadata());
1898-
metadata.put(Msg.METADATA_CONFIRM_REQUEST_REPLY_ID, replyId);
1895+
metadata.put(metadataKey, replyId);
18991896
replaceLastAssistantMsg(lastAssistant.withMetadata(metadata));
19001897
}
19011898

1902-
/**
1903-
* Remove confirmation-request correlation metadata after the resume payload is accepted.
1904-
*
1905-
* <p>Leaving it behind would make later agent turns appear to belong to an already-closed
1906-
* HITL request.
1907-
*/
1908-
private void clearPendingConfirmRequest() {
1899+
/** Remove HITL correlation metadata after the resume payload is accepted. */
1900+
private void clearPendingRequestReplyId(String metadataKey) {
19091901
Msg lastAssistant = findLastAssistantMsg();
19101902
if (lastAssistant == null || lastAssistant.getMetadata() == null) {
19111903
return;
19121904
}
1913-
if (!lastAssistant.getMetadata().containsKey(Msg.METADATA_CONFIRM_REQUEST_REPLY_ID)) {
1905+
if (!lastAssistant.getMetadata().containsKey(metadataKey)) {
19141906
return;
19151907
}
19161908
Map<String, Object> metadata = new HashMap<>(lastAssistant.getMetadata());
1917-
metadata.remove(Msg.METADATA_CONFIRM_REQUEST_REPLY_ID);
1909+
metadata.remove(metadataKey);
19181910
replaceLastAssistantMsg(lastAssistant.withMetadata(metadata));
19191911
}
19201912

@@ -2234,7 +2226,12 @@ private void validateAndAddToolResults(List<Msg> msgs, Set<String> pendingIds) {
22342226
+ ", Pending: "
22352227
+ pendingIds);
22362228
}
2237-
2229+
String replyId =
2230+
resolvePendingRequestReplyId(Msg.METADATA_EXTERNAL_EXECUTION_REQUEST_REPLY_ID);
2231+
if (!replyId.isEmpty()) {
2232+
publishEvent(new ExternalExecutionResultEvent(replyId, results));
2233+
clearPendingRequestReplyId(Msg.METADATA_EXTERNAL_EXECUTION_REQUEST_REPLY_ID);
2234+
}
22382235
state.contextMutable().addAll(msgs);
22392236
}
22402237

@@ -2853,7 +2850,8 @@ Flux<AgentEvent> actingStream(
28532850
// completion;
28542851
// initialise it to empty since no successful execution happened.
28552852
resultHolder.set(List.of());
2856-
persistPendingConfirmRequest(replyId);
2853+
persistPendingRequestReplyId(
2854+
Msg.METADATA_CONFIRM_REQUEST_REPLY_ID, replyId);
28572855
return Flux.<AgentEvent>just(
28582856
new RequireUserConfirmEvent(replyId, pending),
28592857
new RequestStopEvent(
@@ -3046,6 +3044,10 @@ private Flux<AgentEvent> runToolBatch(
30463044
results);
30473045
if (!suspendedCalls
30483046
.isEmpty()) {
3047+
persistPendingRequestReplyId(
3048+
Msg
3049+
.METADATA_EXTERNAL_EXECUTION_REQUEST_REPLY_ID,
3050+
replyId);
30493051
sink.next(
30503052
new RequireExternalExecutionEvent(
30513053
replyId,

agentscope-core/src/main/java/io/agentscope/core/message/Msg.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ public class Msg implements State {
8383
public static final String METADATA_CONFIRM_REQUEST_REPLY_ID =
8484
"agentscope_confirm_request_reply_id";
8585

86+
/**
87+
* Metadata key storing the {@code replyId} of the {@code RequireExternalExecutionEvent} that
88+
* paused this assistant turn. Used to correlate the later
89+
* {@code ExternalExecutionResultEvent}.
90+
*/
91+
public static final String METADATA_EXTERNAL_EXECUTION_REQUEST_REPLY_ID =
92+
"agentscope_external_execution_request_reply_id";
93+
8694
/**
8795
* Metadata key (boolean) marking a message as <em>synthetic</em>: framework-injected rather
8896
* than authored by the user, the model, or a tool. Synthetic messages (e.g. the per-turn todo

agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopReplyTest.java

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
import io.agentscope.core.ReActAgent;
2323
import io.agentscope.core.event.AgentEndEvent;
2424
import io.agentscope.core.event.AgentEvent;
25+
import io.agentscope.core.event.AgentResultEvent;
2526
import io.agentscope.core.event.AgentStartEvent;
2627
import io.agentscope.core.event.ExceedMaxItersEvent;
28+
import io.agentscope.core.event.ExternalExecutionResultEvent;
2729
import io.agentscope.core.event.ModelCallEndEvent;
2830
import io.agentscope.core.event.ModelCallStartEvent;
2931
import io.agentscope.core.event.RequireExternalExecutionEvent;
@@ -312,6 +314,59 @@ void externalToolCallEmitsRequireExternalExecutionEvent() {
312314
assertEquals("external_api", event.getToolCalls().get(0).getName());
313315
}
314316

317+
@Test
318+
void externalToolResultResumeEmitsExternalExecutionResultEvent() {
319+
ChatModelBase model =
320+
new ScriptedModel(
321+
List.of(
322+
() -> Flux.just(toolUseResponse("ext1", "external_api", "/users")),
323+
() -> Flux.just(textResponse("done"))));
324+
ReActAgent agent =
325+
ReActAgent.builder()
326+
.name("asst")
327+
.model(model)
328+
.toolkit(toolkitWithExternalSchema())
329+
.build();
330+
331+
List<AgentEvent> firstEvents = agent.streamEvents(List.of()).collectList().block();
332+
assertNotNull(firstEvents);
333+
int iRequireExternal = indexOf(firstEvents, RequireExternalExecutionEvent.class);
334+
assertTrue(iRequireExternal >= 0, "RequireExternalExecutionEvent expected");
335+
336+
RequireExternalExecutionEvent requireEvent =
337+
(RequireExternalExecutionEvent) firstEvents.get(iRequireExternal);
338+
ToolResultBlock externalResult =
339+
ToolResultBlock.builder()
340+
.id("ext1")
341+
.name("external_api")
342+
.output(TextBlock.builder().text("external result").build())
343+
.state(ToolResultState.SUCCESS)
344+
.build();
345+
Msg resumeMsg = Msg.builder().role(MsgRole.TOOL).content(externalResult).build();
346+
347+
List<AgentEvent> resumedEvents =
348+
agent.streamEvents(List.of(resumeMsg)).collectList().block();
349+
assertNotNull(resumedEvents);
350+
351+
int iExternalResult = indexOf(resumedEvents, ExternalExecutionResultEvent.class);
352+
int iModelStart = indexOf(resumedEvents, ModelCallStartEvent.class);
353+
assertTrue(iExternalResult >= 0, "ExternalExecutionResultEvent expected");
354+
assertTrue(
355+
iModelStart > iExternalResult,
356+
"ExternalExecutionResultEvent should be emitted before resumed reasoning");
357+
358+
ExternalExecutionResultEvent resultEvent =
359+
(ExternalExecutionResultEvent) resumedEvents.get(iExternalResult);
360+
assertEquals(requireEvent.getReplyId(), resultEvent.getReplyId());
361+
assertEquals(1, resultEvent.getToolResults().size());
362+
assertEquals("ext1", resultEvent.getToolResults().get(0).getId());
363+
364+
AgentResultEvent agentResult =
365+
(AgentResultEvent)
366+
resumedEvents.get(indexOf(resumedEvents, AgentResultEvent.class));
367+
assertEquals("done", agentResult.getResult().getTextContent());
368+
}
369+
315370
@Test
316371
void maxItersOverflowEmitsExceedMaxItersEvent() {
317372
Supplier<Flux<ChatResponse>> loop = () -> Flux.just(toolUseResponse("tc", "echo", "x"));

docs/v2/en/docs/building-blocks/agent.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,7 @@ for (var tc : externalEvent.getToolCalls()) {
431431
}
432432
```
433433

434-
**3. Resume the agent** — feed the results back as the next `call`'s input message. The results are injected into the agent context and reasoning continues from where it paused. See `agentscope-examples/documentation/.../hitl/InterruptionExample.java` for a complete walkthrough.
434+
**3. Resume the agent** — feed the results back as the next `call`'s input message. After the results are validated, they are injected into the agent context and the agent emits `ExternalExecutionResultEvent`; its `getReplyId()` matches the earlier `RequireExternalExecutionEvent#getReplyId()`. Reasoning then continues from where it paused.
435435

436436
:::{tip}
437437
Use `streamEvents` when building interactive UIs — it lets you detect pauses in real time and prompt the user immediately. Use `call` for programmatic flows that handle events automatically. Complete runnable examples: `agentscope-examples/documentation/.../hitl/PermissionHITLExample.java`.

docs/v2/en/docs/building-blocks/message-and-event.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,11 @@ Events are grouped below; unless noted otherwise, every event also carries `getR
297297

298298
**RequireExternalExecutionEvent** — agent pauses for external execution.
299299

300+
| Method | Type | Description |
301+
|--------|------|-------------|
302+
| `getReplyId()` | `String` | Reply message ID |
303+
| `getToolCalls()` | `List<ToolUseBlock>` | Tool calls awaiting external execution |
304+
300305
**UserConfirmResultEvent** — emitted when a later `call()` resumes a paused permission HITL request.
301306
It carries one or more `ConfirmResult`s, and its `replyId` matches the earlier `RequireUserConfirmEvent`.
302307

@@ -305,7 +310,13 @@ Events are grouped below; unless noted otherwise, every event also carries `getR
305310
| `getReplyId()` | `String` | Reply ID of the correlated `RequireUserConfirmEvent` |
306311
| `getConfirmResults()` | `List<ConfirmResult>` | Confirmation results accepted for this resume |
307312

308-
**ExternalExecutionResultEvent** — external system returns execution results (input event); carries `List<ToolResultBlock>`.
313+
**ExternalExecutionResultEvent** — emitted when a later `call()` resumes a paused external-execution request.
314+
It carries one or more `ToolResultBlock`s, and its `replyId` matches the earlier `RequireExternalExecutionEvent`.
315+
316+
| Method | Type | Description |
317+
|--------|------|-------------|
318+
| `getReplyId()` | `String` | Reply ID of the correlated `RequireExternalExecutionEvent` |
319+
| `getToolResults()` | `List<ToolResultBlock>` | External execution results accepted for this resume |
309320

310321
**AllToolsDeniedEvent** — the user denied all tool calls from the most recent reasoning step via HITL confirmation. This event is emitted through the `onActing` middleware chain, allowing middlewares to emit a `RequestStopEvent` to stop the agent. If no middleware handles it, the agent continues to the next reasoning iteration (backward compatible).
311322

docs/v2/en/docs/building-blocks/tool.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ public class WebSearchTool extends ToolBase {
173173

174174
### External execution tools
175175

176-
External-execution tools delegate the actual work outside the agent runtime — typically to a human operator or an external system. The agent emits `RequireExternalExecutionEvent` and pauses until the result is fed back via `ExternalExecutionResultEvent`.
176+
External-execution tools delegate the actual work outside the agent runtime — typically to a human operator or an external system. The agent emits `RequireExternalExecutionEvent` and pauses. When the next call feeds back matching `ToolResultBlock`s, the agent emits `ExternalExecutionResultEvent` with the same `replyId` before continuing.
177177

178178
This pattern is the foundation of [human-in-the-loop](./agent.md#human-in-the-loop) flows — some actions need human approval or human execution.
179179

docs/v2/zh/docs/building-blocks/agent.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,7 @@ for (var tc : externalEvent.getToolCalls()) {
431431
}
432432
```
433433

434-
**3. 恢复智能体** —— 将结果作为下一次 `call` 的输入消息回传。结果会被注入智能体上下文,推理从中断处继续。完整示例见 `agentscope-examples/documentation/.../hitl/InterruptionExample.java`
434+
**3. 恢复智能体** —— 将结果作为下一次 `call` 的输入消息回传。结果校验通过后会被注入智能体上下文,agent 会先发出 `ExternalExecutionResultEvent`,其 `getReplyId()` 与之前的 `RequireExternalExecutionEvent#getReplyId()` 相同,然后从中断处继续推理
435435

436436
:::{tip}
437437
构建交互式 UI 时使用 `streamEvents`——它可以实时检测暂停事件并立即提示用户。以编程方式处理事件的自动化流程则使用 `call`。完整可运行示例见 `agentscope-examples/documentation/.../hitl/PermissionHITLExample.java`

docs/v2/zh/docs/building-blocks/message-and-event.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,11 @@ sequenceDiagram
297297

298298
**RequireExternalExecutionEvent** — 智能体暂停等待外部执行。
299299

300+
| 方法 | 类型 | 描述 |
301+
|------|------|------|
302+
| `getReplyId()` | `String` | 回复消息 ID |
303+
| `getToolCalls()` | `List<ToolUseBlock>` | 待外部执行的工具调用列表 |
304+
300305
**UserConfirmResultEvent** — 用户提供确认结果。携带 `List<ConfirmResult>`。
301306
`replyId` 与最初暂停智能体的 `RequireUserConfirmEvent` 相同。
302307

@@ -305,7 +310,13 @@ sequenceDiagram
305310
| `getReplyId()` | `String` | 关联的 `RequireUserConfirmEvent` 的回复 ID |
306311
| `getConfirmResults()` | `List<ConfirmResult>` | 本次恢复接受的确认结果 |
307312

308-
**ExternalExecutionResultEvent** — 外部系统提供执行结果(输入事件)。携带 `List<ToolResultBlock>`。
313+
**ExternalExecutionResultEvent** — 后续 `call()` 恢复外部执行暂停时发出。
314+
携带一个或多个 `ToolResultBlock`,且 `replyId` 与之前的 `RequireExternalExecutionEvent` 相同。
315+
316+
| 方法 | 类型 | 说明 |
317+
|------|------|------|
318+
| `getReplyId()` | `String` | 关联的 `RequireExternalExecutionEvent` 的回复 ID |
319+
| `getToolResults()` | `List<ToolResultBlock>` | 本次恢复接受的外部执行结果 |
309320

310321
**AllToolsDeniedEvent** — 用户通过 HITL 确认拒绝了最近一轮推理产出的全部工具调用。该事件通过 `onActing` middleware 链发出,middleware 可据此发出 `RequestStopEvent` 停止 agent。若无 middleware 处理,agent 默认继续下一轮推理(向后兼容)。
311322

docs/v2/zh/docs/building-blocks/tool.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ public class WebSearchTool extends ToolBase {
173173

174174
### 定义外部执行 Tool
175175

176-
外部执行 tool 把实际执行委派给 agent 运行时之外 —— 通常是人工操作员或外部系统。Agent 调用此类 tool 时会发出 `RequireExternalExecutionEvent` 并暂停,直到结果通过 `ExternalExecutionResultEvent` 回传
176+
外部执行 tool 把实际执行委派给 agent 运行时之外 —— 通常是人工操作员或外部系统。Agent 调用此类 tool 时会发出 `RequireExternalExecutionEvent` 并暂停。下一次调用回传匹配的 `ToolResultBlock` 后,agent 会发出带有相同 `replyId``ExternalExecutionResultEvent`,然后继续执行
177177

178178
这种模式是 [human-in-the-loop](./agent.md) 工作流的基础 —— 某些动作需要人工确认或人工执行。
179179

0 commit comments

Comments
 (0)