agentscope-ai / agentscope-ai/agentscope-java

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

Aberta
#2,362 1 comentário 0 reações 0 responsáveis Ver no GitHub
area/build area/core/agent area/core/memory area/core/tool area/docs bug
Linguagem predominante
Java
Estrelas
5.6k
Forks
1.3k
Merge médio
4d 12h
PRs com merge (30d)
77

Descrição

### 背景

我需要一个「终止工具」语义:某个工具(如 `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 配对。
}
}

Guia de contribuição

Abrir o guia de contribuição

Avaliação

Esta issue ainda não foi avaliada.

Receba novas issues na sua caixa de entrada

Um resumo curto de issues do GitHub para quem está começando.