agentscope-ai / agentscope-ai/agentscope-java

[Bug]:无法实现ClaudeCode的AI思考过程中的纠偏机制,ClaudeCode是中断新建Turn机制;AgentScope2.0.0 中断恢复(interrupt resume)遗留 orphan ToolUseBlock,导致 OpenAI/Anthropic 请求 400

未關閉
#2,191 2 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視
area/core/agent bug
主要語言
Java
星號
5.6k
分支
1.3k
平均合併
4 天 12 小時
30 天內合併 PR
77

描述

## 环境

- agentscope-java **2.0.0**
- 模块:`agentscope-core`(`io.agentscope.core.ReActAgent`)

## 现象

当 agent 在 tool_call **acting 之前**被 `interrupt(msg)` 中断(reasoning 已产出 `ToolUseBlock`、tool 尚未执行),resume(下一次 `call`)后发给 LLM 的消息序列中,该 `ToolUseBlock` 没有配对的 `ToolResultBlock`,违反 OpenAI/Anthropic 消息顺序契约(assistant 的 `tool_use` 后必须跟 `tool_result`),触发 **400 错误**。

`enablePendingToolRecovery=true` 在该路径**未生效**。

## 根因

中断时 `handleInterrupt` 往 context 追加了一条 **assistant** recovery 消息,破坏了 `enablePendingToolRecovery` 的检测前提:

1. `handleInterrupt`(`ReActAgent.java` ~`:3547-3552`)中断时追加 assistant recovery 消息(`"I noticed that you have interrupted me. What can I do for you?"`)。
2. `getPendingToolUseIds`(`ReActAgent.java` ~`:1788`)用 `findLastAssistantMsg()` 检测 pending tool_use,隐含假设"携带 tool_use 的 assistant 是最后一条 assistant"。中断后最后一条 assistant 是 recovery(无 tool_use),故返回**空集**。
3. `maybePatchPendingToolCalls`(`ReActAgent.java` ~`:1693-1694`)见 pendingIds 空直接 `return`,不补 tool_result。
4. 同理 `maybePatchPendingToolCalls` ~`:1709` 的 `findLastAssistantMsg()` 也指向 recovery,即便检测到也补不到正确位置。

中断后 context 形如:

```
[1] USER taskMsg
[2] ASSISTANT ToolUseBlock(tc_slow_1) ← orphan,无 tool_result
[3] ASSISTANT "I noticed that you have interrupted me..." ← handleInterrupt 加的 recovery
[4] USER correctionMsg
```

`[2]` 的 tool_use 无配对 tool_result -> OpenAI/Anthropic 400。

## 预期

resume 序列中每个 `ToolUseBlock` 都有配对的 `ToolResultBlock`(orphan 的由 `enablePendingToolRecovery` 补 error result),消息顺序合法,不触发 400。

## 复现步骤

1. 将下方"复现代码"文件放到 `agentscope-core/src/test/java/io/agentscope/core/interruption/InterruptOrphanToolUseReproTest.java`
2. 运行:`mvn -pl agentscope-core test -Dtest=InterruptOrphanToolUseReproTest`
3. 测试失败,断言报 `orphan tool_use!`

机制说明:`FakeModel` 第 1 次 `call` 返回 `tool_call(slow_tool)`,stream 完成后立即 `interrupt(correctionMsg)`,触发 acting 前检查点(`ReActAgent` ~`:2083` `checkInterrupted`)中断,产生 orphan tool_use;第 2 次 `call(correctionMsg)` resume,断言 resume 序列无 orphan。

## 实测输出(2.0.0)

```
=== resume message sequence (5 msgs) ===
[0] role=SYSTEM content=[你是测试 agent,按用户要求调用工具。]
[1] role=USER content=[请调用 slow_tool 完成任务]
[2] role=ASSISTANT content=[io.agentscope.core.message.ToolUseBlock@d70e9a]
[3] role=ASSISTANT content=[I noticed that you have interrupted me. What can I do for you?]
[4] role=USER content=[停!换个做法:直接回答 42]
toolUseIds=[tc_slow_1]
toolResultIds=[]

org.opentest4j.AssertionFailedError: orphan tool_use! toolUseIds=[tc_slow_1] missing toolResult: [tc_slow_1]
```

`SlowTool.executed=false` 证实中断发生在 acting 之前(orphan 场景成功触发,非 tool 执行后中断)。

## 复现代码

```java
/*
* Copyright 2024-2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.agentscope.core.interruption;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import io.agentscope.core.ReActAgent;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.message.UserMessage;
import io.agentscope.core.model.ChatResponse;
import io.agentscope.core.model.ChatUsage;
import io.agentscope.core.model.GenerateOptions;
import io.agentscope.core.model.Model;
import io.agentscope.core.model.ToolSchema;
import io.agentscope.core.state.InMemoryAgentStateStore;
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.Toolkit;
import io.agentscope.core.util.JsonUtils;
import java.time.Duration;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import reactor.core.publisher.Flux;

/**
* Bug 复现:interrupt resume 遗留 orphan ToolUseBlock -> OpenAI/Anthropic 400.
*
*

Scenario: agent is interrupted by {@code interrupt(msg)} right before acting (reasoning has
* produced a {@link ToolUseBlock}, tool not yet executed). On resume (next {@code call}), the
* message sequence sent to the LLM contains that ToolUseBlock without a paired
* {@link ToolResultBlock}, violating the OpenAI/Anthropic message-order contract (an assistant
* tool_use must be followed by a tool_result) and triggering a 400 error.
* {@code enablePendingToolRecovery=true} does NOT take effect on this path.
*
*

Root cause:
*


    *
  1. {@code handleInterrupt} (ReActAgent ~:3547-3552) appends an assistant recovery
    * message ("I noticed that you have interrupted me...") to the context on interrupt.

  2. *
  3. {@code getPendingToolUseIds} (ReActAgent ~:1788) uses {@code findLastAssistantMsg()} to
    * detect pending tool_use, implicitly assuming "the assistant carrying tool_use is the last
    * assistant". After interrupt, the last assistant is the recovery message (no tool_use),
    * so it returns an empty set.

  4. *
  5. {@code maybePatchPendingToolCalls} (ReActAgent ~:1693-1694) sees empty pendingIds and
    * returns immediately, never synthesizing the missing tool_result.

  6. *

*
*

Expected: every ToolUseBlock in the resume sequence has a paired ToolResultBlock (orphan
* ones patched with an error result), i.e. the message order is legal. Actual (2.0.0):
* {@code toolUseIds=[tc_slow_1]}, {@code toolResultIds=[]}, assertion below fails.
*
*

Suggested fix: make {@code getPendingToolUseIds} scan all assistant messages for
* tool_use without a matching result (not only {@code findLastAssistantMsg()}), and have
* {@code maybePatchPendingToolCalls} insert the synthetic result right after the assistant that
* owns the orphan tool_use; or have {@code handleInterrupt} patch orphan tool_use before
* appending the recovery message.
*/
@Timeout(60)
class InterruptOrphanToolUseReproTest {

private static final String TOOL_USE_ID = "tc_slow_1";

@Test
void interruptDuringToolCall_leavesOrphanToolUse() {
Msg taskMsg = new UserMessage("user", "请调用 slow_tool 完成任务");
Msg correctionMsg = new UserMessage("user", "停!换个做法:直接回答 42");

FakeModel model = new FakeModel(correctionMsg);
Toolkit toolkit = new Toolkit();
toolkit.registerTool(new SlowTool());

ReActAgent agent =
ReActAgent.builder()
.name("interrupt-repro-agent")
.sysPrompt("你是测试 agent,按用户要求调用工具。")
.model(model)
.toolkit(toolkit)
.stateStore(new InMemoryAgentStateStore())
.enablePendingToolRecovery(true)
.maxIters(10)
.build();
model.agent = agent; // injected after build to break the circular dependency

// 1st call: returns tool_call, then interrupt on stream complete -> aborted at the
// pre-acting checkpoint (ReActAgent ~:2083) -> orphan tool_use (tool never runs)
Msg interrupted = agent.call(taskMsg).block(Duration.ofSeconds(30));
assertNotNull(interrupted, "1st call should return (interrupt recovery message)");

// resume
Msg resumeResult = agent.call(correctionMsg).block(Duration.ofSeconds(30));
assertNotNull(resumeResult, "resume call should return");

List resumeMsgs = model.lastMessages;
assertNotNull(resumeMsgs, "resume should send messages to the model");
System.out.println("=== resume message sequence (" + resumeMsgs.size() + " msgs) ===");
for (int i = 0; i < resumeMsgs.size(); i++) {
System.out.println(
"["
+ i
+ "] role="
+ resumeMsgs.get(i).getRole()
+ " content="
+ resumeMsgs.get(i).getContent());
}

Set toolUseIds = new HashSet<>();
Set toolResultIds = new HashSet<>();
for (Msg m : resumeMsgs) {
for (ContentBlock b : m.getContent()) {
if (b instanceof ToolUseBlock tu) {
toolUseIds.add(tu.getId());
} else if (b instanceof ToolResultBlock tr) {
toolResultIds.add(tr.getId());
}
}
}
System.out.println("toolUseIds=" + toolUseIds);
System.out.println("toolResultIds=" + toolResultIds);

// Core assertion: no orphan tool_use (currently FAILS, reproducing the bug)
assertTrue(
toolResultIds.containsAll(toolUseIds),
"orphan tool_use! toolUseIds="
+ toolUseIds
+ " missing toolResult: "
+ diff(toolUseIds, toolResultIds));

// Sanity: the slow tool must NOT have run (interrupt happened before acting)
assertFalse(
SlowTool.executed,
"SlowTool executed -- interrupt happened after acting, orphan scenario not"
+ " triggered");
}

private static Set diff(Set a, Set b) {
Set d = new HashSet<>(a);
d.removeAll(b);
return d;
}

/** Minimal Model mock: 1st call returns a tool_call and fires interrupt on stream complete. */
static final class FakeModel implements Model {
final Msg interruptMsg;
volatile ReActAgent agent;
volatile List lastMessages;
int callCount = 0;

FakeModel(Msg interruptMsg) {
this.interruptMsg = interruptMsg;
}

@Override
public Flux stream(
List messages, List tools, GenerateOptions options) {
callCount++;
lastMessages = messages;
if (callCount == 1) {
return Flux.just(toolCallResponse("slow_tool", TOOL_USE_ID))
.doOnComplete(() -> agent.interrupt(interruptMsg));
}
return Flux.just(textResponse("好的,按纠偏直接回答:42"));
}

@Override
public String getModelName() {
return "fake-model";
}

private static ChatResponse toolCallResponse(String toolName, String id) {
Map args = Map.of();
return ChatResponse.builder()
.id("msg_" + UUID.randomUUID())
.content(
List.of(
ToolUseBlock.builder()
.name(toolName)
.id(id)
.input(args)
.content(JsonUtils.getJsonCodec().toJson(args))
.build()))
.usage(new ChatUsage(8, 15, 23))
.build();
}

private static ChatResponse textResponse(String text) {
return ChatResponse.builder()
.id("msg_" + UUID.randomUUID())
.content(List.of(TextBlock.builder().text(text).build()))
.usage(new ChatUsage(10, 20, 30))
.build();
}
}

/** A slow tool so the interrupt lands before acting (tool never finishes). */
public static class SlowTool {
static volatile boolean executed = false;

@Tool(name = "slow_tool", description = "一个慢工具,模拟长时间执行")
public String slowTool() throws InterruptedException {
executed = true;
Thread.sleep(20000);
return "slow-result";
}
}
}
```

## 修复建议

**方案 A(根本,推荐)**:`getPendingToolUseIds` 扫描**所有** assistant 消息的 tool_use(filter 出无配对 tool_result 的),不再依赖 `findLastAssistantMsg()`:

```java
private Set getPendingToolUseIds() {
Set existingResultIds = state.contextMutable().stream()
.flatMap(m -> m.getContentBlocks(ToolResultBlock.class).stream())
.map(ToolResultBlock::getId)
.collect(Collectors.toSet());
return state.contextMutable().stream()
.filter(m -> m.getRole() == MsgRole.ASSISTANT)
.flatMap(m -> m.getContentBlocks(ToolUseBlock.class).stream())
.map(ToolUseBlock::getId)
.filter(id -> !existingResultIds.contains(id))
.collect(Collectors.toSet());
}
```

**方案 B(配套)**:`maybePatchPendingToolCalls` 补 result 时定位到"含 orphan tool_use 的 assistant",并在其**紧后**位置插入 tool_result(而非 context 末尾),保证 `tool_use -> tool_result` 紧邻,不被 recovery 消息隔断。

**方案 C(最小改动,治标)**:`handleInterrupt` 在追加 recovery 消息**之前**,先补 orphan tool_use 的 error tool_result,避免 recovery 消息遮蔽 `findLastAssistantMsg()`。

建议 **A + B** 组合:使 pending 检测/补全不再依赖"last assistant"假设,覆盖所有多 assistant 场景(中断恢复、压缩、重启等可能产生中间 assistant 的路径)。

## 影响

- 任何在 tool_call acting 前中断 + resume 的场景(如用户纠偏中断续跑)都会触发,导致 OpenAI/Anthropic provider 400。
- 该 bug 使"中断续跑"(interrupt resume)在多模型场景不可用,用户被迫改用"排队续跑"(等当前 turn 跑完再处理新消息)规避。

貢獻指南

開啟貢獻指南

評估

這個 Issue 還沒有評估資料。

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。