agentscope-ai / agentscope-ai/agentscope-java

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

Abierto
#2,191 2 comentarios 0 reacciones 0 asignados Ver en GitHub
area/core/agent bug
Lenguaje dominante
Java
Estrellas
5.6k
Forks
1.3k
Merge medio
4 d 12 h
PR fusionados (30 d)
77

Descripción

## 环境

- 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 跑完再处理新消息)规避。

Guía de contribución

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.