feat(agui): threadId 即会话——服务端持有 transcript + 内核事件级持久化(业务后端不必组装 messages)
Maintainer thường phản hồi trong vòng 1 ngày
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 38/100
- Loại issue
- Tính năng
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- rust
- Lĩnh vực
- backend-api-design
Hướng nghiên cứu
Start with src/http/handlers.rs near build_session_runtime_parts and src/http/agui.rs near build_agui_runtime; compare their CompositeSink setup with existing SessionPersistenceSink wiring in the CLI and TUI. Then trace the existing persist_transcript_per_turn and persist_run paths and relevant tests. Done means HTTP writes completed messages at event granularity without duplicate transcript entries, and the requested threadId/messages behavior is covered by tests.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
更正说明:本条初版把问题描述成「持久化只到 run 级、需要新建事件级机制」。核对代码后这个描述是错的 ——
内核已经有事件级持久化(SessionPersistenceSink,CLI/TUI 在用)。真实缺口是
HTTP 通道没有接这个 sink。已按实际代码重写。诉求不变,但方案小得多。
一、真实缺口:HTTP 通道没接内核已有的事件级持久化
内核侧已经做对了
src/run_core.rs:303(push_message / push_tool_result)的文档写得非常明确:
Push a message onto the transcript and announce it as [
AgentEvent::MessageAppended] in the same breath,
so persistence sinks (SessionPersistenceSink) write the row totranscript.jsonlimmediately instead
of waiting for the turn to end. A process killed mid-turn (SIGKILL / SIGTERM force-exit) therefore
keeps every already completed step's messages, not just those that survived to turn end.
SessionPersistenceSink(src/session/writer.rs:708,lib.rs:139 导出)就是那个 sink。
但只有 CLI / TUI 接了它
crates/recursive-cli/src/main.rs:2672, 2955, 3814, 3862 ← CLI run/repl/loop
crates/recursive-cli/src/cli/resume.rs:715 ← CLI resume
crates/recursive-tui/src/backend.rs:849, 910 ← TUI
HTTP 侧完全没有接。 实测两处的 CompositeSink 组成:
| 位置 | 接的 sink |
|---|---|
src/http/handlers.rs:2070-2075(REST 会话) |
EnvelopeSink + MetricsSink + Langfuse |
src/http/agui.rs:1036-1043(AG-UI) |
ChannelSink + MetricsSink |
没有 SessionPersistenceSink。
后果
| 通道 | 崩溃窗口 |
|---|---|
| CLI / TUI | 单条消息(每个 MessageAppended 落盘) |
| HTTP / REST | 一整个 turn —— 靠 drive_turn 末尾的 persist_transcript_per_turn |
| HTTP / AG-UI | 一整个 run —— 靠 agui.rs:1227 run 结束后的 persist_run |
一个 agentic run 可能跑几分钟(多轮 LLM + 多个工具调用 + 可能 20+ step)。
这期间磁盘上什么都没有;进程被 OOM/SIGKILL,整个 run 的工作 + 已烧掉的 token 一起丢。
(#115 已经在关心「失败 run 的 token 也要计费」,但那是 run 结束后才写的。)
而且这顺带解决了 #92 为 AG-UI 留的那个洞
handlers.rs:498 解释了为什么 AG-UI 不能用 persist_transcript_per_turn:
AG-UI … reseeds its transcript from the client-supplied
messageson every run,
so the append watermark premise ("on-disk prefix == runtime prefix") does not hold there
但 SessionPersistenceSink 根本不依赖那个前提 —— 它消费的是 MessageAppended 事件,
逐行 append 到 transcript.jsonl,与 runtime 的 transcript 前缀无关。
所以 AG-UI 那个「水位线不成立」的理由,对这条路径不适用。 AG-UI 也能拿到单条消息粒度的持久化。
二、方案(很小)
在 HTTP 两个运行时装配点,把 SessionPersistenceSink 加进 CompositeSink:
src/http/handlers.rs(REST,build_session_runtime_parts附近)src/http/agui.rs(AG-UI,build_agui_runtime)
要点:
- 复用现成 sink,不新增内核机制
- 需要拿到该 session 的
SessionWriter(sink 的构造参数)—— REST 侧create_session/fork_session/
cold_load已经持有 session 目录与 writer 语义;AG-UI 侧agui_session::resolve_session_dir+
SessionWriter::open_or_create已是persist_run的既有路径,可直接复用 - 注意与既有落盘的重复写:
persist_transcript_per_turn与persist_run仍在,
需要明确二者关系 —— 建议 sink 为准(事件级),turn/run 末的批量落盘退化为兜底或移除,
否则同一批消息会被写两次
(runtime/builder.rs:400已有一条相关注释:「…through their own sink, and the kernel must not double-write」) - 仍不落盘流式事件:
PartialToken/PartialReasoning不进SessionPersistenceSink
(它本来就只匹配MessageAppended/MessageAppendedWithAudit,见event.rs:366)
这一条我认为是「接线」而不是「造机制」,所以成本远低于初版描述。
三、诉求(不变)
C 端的接入方通常不是纯前端,而是业务侧后端(AG-UI 官方架构里的 Secure Proxy)。两个诉求:
threadId就是会话 id —— 有它就能继续对话,不需要独立的resume概念;messages可以为空,
业务后端不必组装历史。- 持久化到事件级(本节一)—— 崩溃窗口 = 单条消息,而不是一整个 run。
关于 messages 语义(契约变更,想请确认)
今天 messages 被解释为完整历史(agui.rs:528:"the server keeps no other per-thread context"),
且空 messages 直接 400:
.ok_or_else(|| PrepareAguiError::BadRequest(
"RunAgentInput must contain at least one user message or a non-empty context item".into(),
))?
建议改为规则制(不加 mode 字段):
- 线程已存在 ⇒ 服务端持久化的 transcript 为准;请求里的
messages只取最后一条 user 消息作为本轮输入 - 线程不存在 ⇒ 沿用今天的行为,从
messages播种(CopilotKit 等标准客户端不受影响) - 空
messages⇒ 线程存在时合法
代价(需写进文档):客户端侧对历史的改写/裁剪不会生效 —— 历史由服务端持有。
四、明确不做
- 不破坏标准 AG-UI 客户端(
messages非空 + 线程不存在 ⇒ 行为与今天一致) - 不落盘流式事件
- 不新增
mode字段 - 不与
#146(身份贯通)合并 —— 前提有重叠、诉求不同
五、外部验收判据(我会怎么验,不改产品代码)
- 事件级持久化(关键判据):跑一个长 run(多工具调用),在 run 进行中 SIGKILL,
重启后断言 —— 已完成的部分在位,且cost.json的 token 数不为 0。
这条能把「事件级」与「turn/run 级」直接区分开。
同样方法对 CLI 跑一遍应已经通过(对照组,证明判据本身有效)。 messages: []合法(RFC 1 的入口)- threadId 即会话:只发
{threadId, messages: []}或只带最后一条 user → 第二轮应记得第一轮
(随机 nonce + 两轮之间清空磁盘旁路的排除法) - 标准模式回归:
messages全量重发的既有路径行为不变 - 无重复写:同一批消息不应在 transcript 中出现两次
六、建议落地顺序
- HTTP 接
SessionPersistenceSink(REST 与 AG-UI 两处)—— 主体,但属接线 - 明确 sink 与
persist_transcript_per_turn/persist_run的关系(去重或降级为兜底) messages语义规则 + 空messages合法 —— 契约变更,可独立评估
关联
#57(AG-UI 落为原生 session)· #62(input.messages 语义)· #92(per-turn 持久化;
本条指出它的水位线理由对 sink 路径不适用)· #97(SSE 重放)· #115(失败 run 的 token)·
#146(身份贯通,不合并)
优先级
P1。理由:内核层已经做对了,缺的只是一处接线;而收益是 HTTP 两个通道的崩溃窗口
从「一个 run」缩到「一条消息」。
- Ngôn ngữ chính
- Rust
- Star
- 4
- Fork
- 0
- Merge trung bình
- 5 giờ 32 phút
- Pull request đã merge (30 ngày)
- 7
Chuẩn bị môi trường
- Có Dockerfile hoặc tệp Docker Compose
- Không có mẫu pull request
- Không có hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của jeffkit/recursive
-
fix(pipeline): preflight kill-stale 在 v2 console/worker 路径把并发兄弟 run 当孤儿杀——同仓并发 run 互杀成链(今日 4 例实证)Đang mở
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 35/100
jeffkit/recursive#148 · 2 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 25/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 35/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 20/100
jeffkit/recursive#134 · 6 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 30/100
jeffkit/recursive#132 · 3 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của jeffkit/recursive
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
pact-foundation/pact-cli#154 ·
Maintainer thường phản hồi trong vòng 3 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
antithesishq/bombadil#361 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
test(executor_l0): assert execute() TaskOutcome, not only bus events / 断言 execute() 返回的 TaskOutcomeĐang mởtype:debt
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 62/100
skaiy/wild_agentos#425 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Default-import note suggests `import * as process` for velt:process, which does not name the builtinĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
Maintainer thường phản hồi trong vòng 1 ngày
-
bug ticket
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
cratestack/cratestack#1154 ·
Maintainer thường phản hồi trong vòng 1 ngày