agentscope-ai / agentscope-ai/agentscope-java

[Bug]:streamEvents() 在绑定 RuntimeContext 之前就装配了 onAgent 中间件链 —— 导致长期记忆与会话落盘写入匿名 _default 命名空间

Đang mở
#1,681 0 bình luận 0 reaction 0 người được giao Xem trên GitHub
area/core/agent area/harness bug
Ngôn ngữ chính
Java
Star
5.6k
Fork
1.3k
Merge trung bình
4 ngày 12 giờ
Pull request đã merge (30 ngày)
77

Mô tả

---
标题: streamEvents() 在绑定 RuntimeContext 之前就装配了 onAgent 中间件链 —— 导致长期记忆与会话落盘写入匿名 _default 命名空间

受影响版本: 2.0.0-RC1(当前 main 同样存在)

涉及组件: agentscope-core(ReActAgent、AgentBase、MiddlewareChain)、agentscope-harness(MemoryFlushMiddleware、MemoryMaintenanceMiddleware、Re
moteFilesystemSpec)

---
概述

通过流式 API ReActAgent.streamEvents(List, RuntimeContext) 调用 agent 时,onAgent 中间件链是即时装配并执行的,发生在每次调用的
RuntimeContext 被绑定到 agent 之前。任何在 onAgent 中读取 agent.getRuntimeContext() 的中间件,此刻拿到的都是 null,于是回退成
RuntimeContext.empty()。

对记忆相关中间件而言,这会让 userId 解析成匿名兜底值 _default、sessionId 解析成 "default",结果是长期记忆 flush、消息 offload、MEMORY.md
合并全部被持久化到错误的命名空间(users/_default/...),而不是调用方真正的用户命名空间。非流式的 call(...) 路径不受影响。

根因(执行顺序)

ReActAgent.streamEvents(msgs, ctx) 只是把 context 暂存为 pending,随即委托下去:

// ReActAgent.java:558-561
public Flux streamEvents(List msgs, RuntimeContext context) {
this.pendingRuntimeContext = context; // 仅暂存,尚未绑定
return streamEvents(msgs);
}

streamEvents(msgs) 立刻装配并 apply 整条链:

// ReActAgent.java:533-534
return MiddlewareChain.build(middlewares, this, MiddlewareBase::onAgent, core)
.apply(new AgentInput(msgs == null ? List.of() : msgs));

MiddlewareChain.build(...).apply(...) 会在 装配时同步 执行每个 onAgent 方法体(它只是普通的嵌套函数组合,没有 defer):

// MiddlewareChain.java:54-58
Function> chain = core;
for (int i = middlewares.size() - 1; i >= 0; i--) {
MiddlewareBase mw = middlewares.get(i);
Function> next = chain;
chain = input -> method.apply(mw, agent, input, next); // .apply() 调用时 onAgent 即执行
}

而 pending context 要等到稍后、在被订阅的 core 内部(call() → beforeAgentExecution)才真正绑定:

// ReActAgent.java:356-363
protected void beforeAgentExecution(List msgs) {
RuntimeContext ctx = this.pendingRuntimeContext; // 在这里才消费
this.pendingRuntimeContext = null;
if (ctx == null) ctx = RuntimeContext.empty();
bindRuntimeContextToHooks(ctx); // 设置 currentRuntimeContext
...
}

// AgentBase.java:533-535
public RuntimeContext getRuntimeContext() { return currentRuntimeContext.get(); }

因此 onAgent 执行的那一刻,currentRuntimeContext 仍是 null。记忆中间件抓到的是空 context:

// MemoryFlushMiddleware.java:61-64(MemoryMaintenanceMiddleware.java:91-94 完全一致)
final RuntimeContext rc =
agent instanceof AgentBase ab && ab.getRuntimeContext() != null
? ab.getRuntimeContext() : RuntimeContext.empty(); // 流式路径下 -> empty
return next.apply(input).doOnComplete(() -> doFlush(agent, rc).subscribe());

继续往下,就得到匿名命名空间 + "default" 会话 id:

// MemoryFlushMiddleware.java:94
String sessionId = rc != null && rc.getSessionId() != null ? rc.getSessionId() : "default";
// RemoteFilesystemSpec.java:78 -> anonymousUserId = "_default"
// RemoteFilesystemSpec.java:259
String effectiveUid = (uid != null && !uid.isBlank()) ? uid : anonymousUserId; // null -> "_default"

为什么 call() 不受影响

在 AgentBase.call(...) 中,beforeAgentExecution(msgs) 运行在 Mono.using 的 resource 步骤里,也就是在 doCall 装配/执行之前。等到 onAgent 读取
getRuntimeContext() 时,context 已经绑定好了,记忆会正确落到调用方的 userId 下:

// AgentBase.java:250-267(call 路径)
return Mono.using(this::acquireExecution,
resource -> { beforeAgentExecution(msgs); return ...doCall... }, // 装配前先绑定
this::releaseExecution, true);

复现步骤

1. 构建一个带远程文件系统 + 用户隔离的 HarnessAgent:
HarnessAgent.builder()
.name("assistant").model(model)
.filesystem(new RemoteFilesystemSpec(new RedisStore(jedis))
.isolationScope(IsolationScope.USER))
.session(distributedSession) // 任意非 WorkspaceSession
.build();
2. 用带有身份的 context 走流式调用:
RuntimeContext ctx = RuntimeContext.builder().userId("alice").sessionId("s1").build();
agent.streamEvents(List.of(new UserMessage("记住我喜欢喝美式")), ctx).blockLast();
3. 检查存储后端。长期记忆 / 会话 offload / 合并都被写到了 agents//users/_default/... 以及 .../sessions/default.*,而不是 users/alice/...。
- 对照:同样的调用换成 agent.call(msgs, ctx).block(),则正确写入 users/alice/...。

预期 vs 实际

- 预期: 流式与非流式调用解析到相同的 RuntimeContext;streamEvents(msgs, ctx) 的记忆/会话应落到 users/alice/...。
- 实际: 流式调用把记忆/会话写到了匿名的 users/_default/...(会话 id 还成了 "default")。

影响

- 流式路径下长期记忆的多用户隔离失效: 所有用户的流式记忆都汇入同一个共享 _default
桶,导致用户永远召回不到自己的事实,且不同用户的记忆相互串扰。
- 隐蔽性强: 工具执行和会话状态持久化看起来都正常(它们用的是已绑定的 context / build 期的 SessionKey),所以不翻存储后端很难发现。
- 影响所有在 onAgent 中即时读取 getRuntimeContext() 的中间件,不仅限于记忆中间件。

修复建议

让流式路径在 apply onAgent 链之前先绑定 RuntimeContext,与 call() 路径(beforeAgentExecution 先行)保持一致。可选方案:
1. 在 ReActAgent.streamEvents(msgs) 中,于链 apply 前后绑定/解绑 pendingRuntimeContext,而不是依赖之后在 core 内部才执行的
beforeAgentExecution;或
2. 把链的装配延迟(defer),使 onAgent 在 beforeAgentExecution 之后执行;或
3. 让 MemoryFlushMiddleware / MemoryMaintenanceMiddleware 在执行/完成阶段再解析 context,而非在装配阶段捕获 ——
但需注意届时绑定必须仍然有效,因此方案 1/2 更优。

建议补一个回归测试:断言 streamEvents(msgs, ctx) 与 call(msgs, ctx) 写入同一命名空间。

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Đánh giá

Issue này chưa được đánh giá.

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.