agentscope-ai / agentscope-ai/agentscope-java

[Bug]:缺少「终止工具」的干净实现:onActing 停止会产生孤儿 tool_result,onModelCall/onReasoning 停止会丢失已落库记忆

Abierto
#2,362 1 comentario 0 reacciones 0 asignados Ver en GitHub
area/build area/core/agent area/core/memory area/core/tool area/docs bug
Lenguaje dominante
Java
Estrellas
5.6k
Forks
1.3k
Merge medio
4 d 12 h
PR fusionados (30 d)
77

Descripción

### 背景

我需要一个「终止工具」语义:某个工具(如 `respond`)执行完后本轮 ReAct 立即结束,且要求
①持久化的 AgentState 里 `tool_use`/`tool_result` 配对完整;②跳过那次多余的「收尾复述」推理。
但在 agentscope-core 2.0.0 里,5 个 middleware 位置都做不到,唯一可行的是已 `@Deprecated(forRemoval)`
的 `PostActingEvent.stopAgent()`。

### 为什么每种中间件都不行(ReActAgent 2.0.0)

- **onActing 发 `RequestStopEvent`**(即文档「全部工具被拒时停止」的写法):`acting(...)` 捕获到 stop 后
直接返回 `buildStopMsg(...)`,**跳过 `notifyPostActingHook`**——而后者是成功工具 `tool_result` 落进
上下文的唯一位置(`contextMutable().add(resultMsg)`)。结果记忆里只有 `tool_use`、缺配对 `tool_result`
(孤儿),下一轮报「pending tool calls without results」/ provider 400(参见 #1692)。

- **onModelCall / onReasoning 发 `RequestStopEvent` 且不调 `next`**(想顺便省掉最贵的收尾推理调用):
模型没被调用 → `ReasoningContext.buildFinalMessage()` 返回 `null` → `reasoning(...)` 返回空 Mono。
而 `doCall(...)` 里 `doCallInner(msgs).flatMap(result -> saveStateToSession(scope)...)` 在上游为空时
**跳过 flatMap** → `saveStateToSession` 不执行 → **整轮记忆不落库(远程/Redis state store 上就是记忆丢失)**。

- **PostActingEvent.stopAgent()(Hook)**:`notifyPostActingHook` 先把 `tool_result` 落上下文,`acting(...)`
再检测 `isStopRequested()` 返回带 `ACTING_STOP_REQUESTED` 的完整消息(非空)→ 正常落库、配对完整、无孤儿、
且省掉收尾推理。这是唯一干净路径,但 Hook 已弃用、且 5 个 middleware 位置没有等价插桩点。

### 期望

提供一个非弃用的「终止工具」一等能力,任选其一即可:
1. 工具级标记(如 `@Tool(terminal=true)`),命中后在结果落库后立即停止;
2. 一个 `PostActing` middleware 位置(在 `notifyPostActingHook` 之后触发,可请求停止);
3. 让 `onActing` 的 `RequestStopEvent` 在停止前仍执行 `notifyPostActingHook`(先持久化成功结果);
4. 让 onModelCall/onReasoning 的短路停止在「已捕获 RequestStopEvent 但 finalMessage 为空」时也执行
`saveStateToSession`。

### 环境

- agentscope-core / agentscope-harness:2.0.0;Java 21;DashScope(qwen) 流式;自定义 Redis AgentStateStore;
HarnessAgent(开启 compaction,关闭 filesystem/shell/subagents)。

### 关键源码位置(ReActAgent 2.0.0)

- `acting(...)` 停止分支返回 `buildStopMsg(...)`,跳过 `notifyPostActingHook`
- `notifyPostActingHook(...)`:`contextMutable().add(resultMsg)`(成功 tool_result 唯一落库点)+ 响应 `stopAgent()`
- `reasoning(...)`:无 chunk 时 `buildFinalMessage()` 返回 null → 空 Mono
- `doCall(...)`:`doCallInner(msgs).flatMap(result -> saveStateToSession(scope)...)` → 空结果不落库

### 最小可复现demo
package io.agentscope.repro;

import io.agentscope.core.ReActAgent;
import io.agentscope.core.agent.Agent;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.event.AgentEvent;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.UserMessage;
import io.agentscope.core.middleware.MiddlewareBase;
import io.agentscope.core.middleware.ModelCallInput;
import io.agentscope.core.state.AgentStateStore;
import io.agentscope.core.state.InMemoryAgentStateStore;
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolParam;
import io.agentscope.core.tool.Toolkit;
import io.agentscope.extensions.model.dashscope.DashScopeChatModel;
import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter;
import reactor.core.publisher.Flux;

import java.util.List;
import java.util.function.Function;
import java.util.concurrent.atomic.AtomicInteger;

/**
* 复现:一个「终止工具」respond() 执行完后,ReAct 循环仍会多发起一次「收尾复述」模型调用。
* 运行前设置环境变量 DASHSCOPE_API_KEY。
*
* 期望:单轮里,模型调用 = 1(模型产出 respond 的 tool_call 即应结束)。
* 实际:模型调用 = 2(第 2 次纯复述、无 tool_call 才结束)—— 这次调用最贵却无价值。
*
* 想省掉第 2 次却都失败:
* - 若在 onActing 里发 RequestStopEvent 停止:respond 的 tool_result 不会被 notifyPostActingHook 落库 → 孤儿 tool_use。
* - 若在 onModelCall 里不调 next 直接发 RequestStopEvent:finalMessage 为空 → doCall 的 flatMap 被跳过 → 整轮不落库。
* 唯一干净:PostActingEvent.stopAgent()(已 @Deprecated)。
*/
public class TerminalToolRepro {

/** 终止工具:本轮唯一收尾动作。 */
public static class RespondTool {
@Tool(name = "respond", description = "本轮唯一收尾工具:给出最终答复后本轮应立即结束。")
public String respond(
@ToolParam(name = "content", description = "最终答复正文") String content) {
return "已答复: " + content;
}
}

/** 统计本轮真实模型调用次数。 */
static class ModelCallCounter implements MiddlewareBase {
final AtomicInteger count = new AtomicInteger();
@Override
public Flux onModelCall(Agent agent, RuntimeContext ctx,
ModelCallInput input, Function> next) {
System.out.println(">>> model call #" + count.incrementAndGet());
return next.apply(input);
}
}

public static void main(String[] args) {
DashScopeChatModel model = DashScopeChatModel.builder()
.apiKey(System.getenv("DASHSCOPE_API_KEY"))
.modelName("qwen-plus")
.formatter(new DashScopeChatFormatter())
.build();

Toolkit toolkit = new Toolkit();
toolkit.registerTool(new RespondTool());

AgentStateStore store = new InMemoryAgentStateStore();
ModelCallCounter counter = new ModelCallCounter();

ReActAgent agent = ReActAgent.builder()
.name("repro")
.sysPrompt("你是客服。收到用户消息后,必须调用一次 respond(content) 收尾,不要输出纯文本。")
.model(model)
.toolkit(toolkit)
.stateStore(store)
.maxIters(6)
.middlewares(List.of(counter))
.build();

RuntimeContext ctx = RuntimeContext.builder().userId("u1").sessionId("s1").build();
Msg reply = agent.call(List.of(new UserMessage("你好")), ctx).block();

System.out.println("最终消息 generateReason = "
+ (reply != null ? reply.getGenerateReason() : null));
System.out.println("本轮模型调用总次数 = " + counter.count.get()
+ " (期望 1,实际通常 2:第 2 次是多余的收尾复述)");
// 进一步可打印 store.getList("u1","s1","memory_messages", Msg.class) 观察记忆里的 tool_use/tool_result 配对。
}
}

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.