From 91e8d7a02938d89ee40c81f3f00c7e1efeb02ef1 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 3 Oct 2026 22:28:21 +0800 Subject: [PATCH] =?UTF-8?q?docs(runtime):=20internal/runtime/loop=2044=20?= =?UTF-8?q?=E4=B8=AA=E6=96=87=E4=BB=B6=E6=B3=A8=E9=87=8A=E6=94=B9=E4=B8=AD?= =?UTF-8?q?=E6=96=87=EF=BC=88=E9=80=90=E8=A1=8C=201:1=EF=BC=8C=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E9=9B=B6=E5=AD=97=E8=8A=82=E6=94=B9=E5=8A=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人 2026-10-03 拍板「注释一律中文」;本模块按长期分批协议(一个模块一次派工)执行。 注释行数 1329 → 1329 逐行 1:1(含中文 33 → 1304);行尾注释 5 条一并译。 校验:机械校验(非注释改动行)= 空;独立 Go AST 程序(剥注释复渲)证明 44/44 文件代码全等; 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。 gofmt:capacity_loop_test.go / flow.go 改动前即未格式化,按铁律保留原样未动。 --- internal/runtime/loop/assemble_test.go | 36 +-- internal/runtime/loop/attribution.go | 116 ++++---- internal/runtime/loop/attribution_test.go | 72 ++--- internal/runtime/loop/capacity_ctx.go | 16 +- .../runtime/loop/capacity_l4_loop_test.go | 28 +- internal/runtime/loop/capacity_loop_test.go | 30 +- internal/runtime/loop/checkpoint.go | 68 ++--- internal/runtime/loop/compactor.go | 124 ++++----- internal/runtime/loop/compactor_test.go | 30 +- internal/runtime/loop/flow.go | 66 ++--- internal/runtime/loop/flow_branch.go | 52 ++-- internal/runtime/loop/flow_test.go | 14 +- internal/runtime/loop/loop.go | 104 +++---- internal/runtime/loop/loop_extra_test.go | 20 +- internal/runtime/loop/loop_test.go | 12 +- internal/runtime/loop/memory.go | 180 ++++++------ internal/runtime/loop/memory_async_test.go | 50 ++-- internal/runtime/loop/memory_gating_test.go | 38 +-- internal/runtime/loop/mocks_test.go | 40 +-- internal/runtime/loop/mute_ctx.go | 22 +- .../runtime/loop/native_toolcall_http_test.go | 20 +- internal/runtime/loop/native_toolcall_test.go | 34 +-- internal/runtime/loop/options.go | 258 +++++++++--------- internal/runtime/loop/persona.go | 68 ++--- internal/runtime/loop/persona_test.go | 100 +++---- internal/runtime/loop/request.go | 74 ++--- internal/runtime/loop/resume_test.go | 56 ++-- internal/runtime/loop/run.go | 74 ++--- internal/runtime/loop/session_ctx.go | 38 +-- internal/runtime/loop/sessionenv.go | 84 +++--- internal/runtime/loop/spanlog.go | 56 ++-- internal/runtime/loop/step.go | 36 +-- internal/runtime/loop/stream.go | 48 ++-- internal/runtime/loop/stream_scope_test.go | 24 +- internal/runtime/loop/thinking_gate_test.go | 32 +-- internal/runtime/loop/time.go | 18 +- internal/runtime/loop/tool_history.go | 18 +- internal/runtime/loop/tool_policy.go | 54 ++-- .../runtime/loop/tool_policy_gating_test.go | 52 ++-- internal/runtime/loop/tools.go | 222 +++++++-------- internal/runtime/loop/trace.go | 22 +- internal/runtime/loop/turn.go | 58 ++-- internal/runtime/loop/turn_test.go | 28 +- internal/runtime/loop/vision.go | 38 +-- 44 files changed, 1315 insertions(+), 1315 deletions(-) diff --git a/internal/runtime/loop/assemble_test.go b/internal/runtime/loop/assemble_test.go index b5b2aa7..0cc6ab4 100644 --- a/internal/runtime/loop/assemble_test.go +++ b/internal/runtime/loop/assemble_test.go @@ -9,9 +9,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// TestAssembleContext_ReRetrievesEachRound proves memory is re-retrieved every -// round rather than cached from the first turn: an empty store yields no memory -// block, then after seeding the store a later round picks it up. +// TestAssembleContext_ReRetrievesEachRound 证明记忆每轮都重新 +// 检索,而非从首轮缓存:空 store 产出无记忆块, +// 随后在 store 中播种后,之后的轮次能取到它。 func TestAssembleContext_ReRetrievesEachRound(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) @@ -20,12 +20,12 @@ func TestAssembleContext_ReRetrievesEachRound(t *testing.T) { msgs := []model.Message{model.TextMessage(model.RoleUser, "do the thing")} - // Round 1: the store is empty, so no memory is injected. + // 第 1 轮:store 为空,因此不注入记忆。 if view := l.assembleContext(context.Background(), msgs, 1); len(view) != 1 { t.Fatalf("round 1 view = %d messages, want 1 (not memory)", len(view)) } - // Seed a memory as if it was learned between rounds. + // 播种一条记忆,如同它在轮次之间被学到。 if _, err := store.Add(context.Background(), memory.MemoryItem{ PersonaID: "persona-a", Content: "她喜欢冰美式", Scope: memory.ScopePersona, Kind: memory.KindRelationship, @@ -33,7 +33,7 @@ func TestAssembleContext_ReRetrievesEachRound(t *testing.T) { t.Fatalf("seed memory: %v", err) } - // Round 2: the retrieval is re-run and the memory now appears. + // 第 2 轮:检索重新运行,该记忆现在出现。 view := l.assembleContext(context.Background(), msgs, 2) if len(view) != 2 { t.Fatalf("round 2 view = %d messages, want 2 (memory injected)", len(view)) @@ -43,8 +43,8 @@ func TestAssembleContext_ReRetrievesEachRound(t *testing.T) { } } -// TestCompactIfNeeded_OverBudget_Compacts drives a multi-turn conversation over a -// small budget and verifies the loop compacts it into a leading summary. +// TestCompactIfNeeded_OverBudget_Compacts 驱动一段超出小预算的 +// 多轮对话,并验证循环把它压缩为一条前导摘要。 func TestCompactIfNeeded_OverBudget_Compacts(t *testing.T) { t.Parallel() l := New(Options{MaxContextTokens: 1000}) @@ -61,11 +61,11 @@ func TestCompactIfNeeded_OverBudget_Compacts(t *testing.T) { } } -// TestCompactIfNeeded_Disabled_NoOp keeps the pre-existing behavior when -// MaxContextTokens is left at its zero value. +// TestCompactIfNeeded_Disabled_NoOp 在 MaxContextTokens +// 保持零值时保留既有行为。 func TestCompactIfNeeded_Disabled_NoOp(t *testing.T) { t.Parallel() - l := New(Options{}) // MaxContextTokens == 0 → disabled + l := New(Options{}) // MaxContextTokens == 0 → 已关闭 big := buildTurns(6, 300, false) got := l.compactIfNeeded(context.Background(), big) if len(got) != len(big) { @@ -73,9 +73,9 @@ func TestCompactIfNeeded_Disabled_NoOp(t *testing.T) { } } -// TestRun_TransformContext_InvokedEachRound verifies the TransformContext hook is -// called once per model step (per round), bridging per-round assemble into the -// Run path. +// TestRun_TransformContext_InvokedEachRound 验证 TransformContext 钩子 +// 每个模型步(每轮)调用一次,把每轮的 assemble 桥接 +// 进 Run 路径。 func TestRun_TransformContext_InvokedEachRound(t *testing.T) { t.Parallel() call := `{"tool_calls":[{"name":"echo","args":{"q":"x"}}]}` @@ -107,15 +107,15 @@ func TestRun_TransformContext_InvokedEachRound(t *testing.T) { if rounds[0] != 1 || rounds[1] != 2 { t.Fatalf("rounds passed to TransformContext = %v, want [1 2]", rounds) } - // Round 1 has just the user message; round 2 has the fed-back tool result. + // 第 1 轮只有用户消息;第 2 轮有回灌的工具结果。 if sizes[0] != 1 || sizes[1] != 2 { t.Fatalf("sizes = %v, want [1 2]", sizes) } } -// TestRun_ContextOverflow_CompactsHistory passes a long multi-turn history and a -// small budget into Run; the first model step should see a compacted conversation -// carrying a leading summary. +// TestRun_ContextOverflow_CompactsHistory 把一段长的多轮历史和一个小 +// 预算传入 Run;第一个模型步应看到一段带前导摘要的 +// 压缩后对话。 func TestRun_ContextOverflow_CompactsHistory(t *testing.T) { t.Parallel() ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "final answer"}}}} diff --git a/internal/runtime/loop/attribution.go b/internal/runtime/loop/attribution.go index 5e480db..067f3b5 100644 --- a/internal/runtime/loop/attribution.go +++ b/internal/runtime/loop/attribution.go @@ -7,46 +7,46 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// This file owns the speaker attribution of the model context (DevDocs/design/22 -// §7-5). A process now hosts several roles at once and a session keeps its -// conversation when its role is switched, so the model must be able to tell which -// utterances in the context came from which role: the assistant messages of a -// previous role must not read as "something I said", and the role answering now -// must recognize itself. +// 本文件拥有模型上下文的发言人归属(DevDocs/design/22 +// §7-5)。一个进程现在同时承载多个角色,且会话在切换角色时 +// 保留其对话,因此模型必须能分辨上下文中的哪些话 +// 出自哪个角色:前一角色的 assistant 消息不得读作 +// 「我说过的话」,而当前作答的角色必须能认出 +// 自己。 // -// Two hard rules shape the rendering: -// - The attributed metadata never enters a message body. A user message is -// free text from the outside world, so letting it carry system-looking markup -// would hand whoever writes it a chance to impersonate a system instruction -// (design/11 §10). Attribution therefore travels as its own system messages, -// never as a prefix inside user or assistant text. -// - The rendering only ADDS system messages. It never reorders, rewrites or -// drops a message, so the assembled prompt keeps its established shape: the -// agent's instructions stay the first system message and the time / working -// memory / memory blocks keep their existing order (assembleContext owns the -// placement; this file only supplies the extra segment). +// 两条硬规则塑造了渲染方式: +// - 归属元数据绝不进入消息正文。用户消息是来自外部世界的 +// 自由文本,因此让它携带看似系统的标记,就等于 +// 给写下它的人一次冒充系统指令的机会 +// (design/11 §10)。因此归属以其自身的系统消息传递, +// 绝不作为用户或 assistant 文本内部的前缀。 +// - 渲染只「新增」系统消息。它绝不重排、改写或 +// 丢弃消息,因此组装出的提示词保持既定形态:agent 的 +// 指令仍是第一条系统消息,时间 / 工作记忆 / 记忆块保持 +// 既有顺序(位置由 assembleContext 拥有;本文件只提供 +// 追加的片段)。 // -// The per-message speaker is read from the structured model.Message.SenderName -// lane, because model/** is frozen in this batch and a Message has no dedicated -// persona field. Only assistant messages are read: for an assistant turn the -// sender IS the role that spoke it, while a labelled user message keeps its -// pre-existing meaning (the third-party sender marker of design/16 §4.6), which -// this file deliberately leaves alone. +// 每条消息的发言人从结构化的 model.Message.SenderName +// 车道读取,因为本批次 model/** 已冻结,Message 没有专门的 +// persona 字段。只读 assistant 消息:对 assistant 轮, +// 发送者正是说出它的角色,而带标签的用户消息保持其 +// 既有含义(design/16 §4.6 的第三方发送者标记), +// 本文件刻意不碰它。 -// Attribution markers. Both are compile-time constants so no magic string -// appears in the run path and a test can assert the exact wire shape. +// 归属标记。两者都是编译期常量,使运行路径中 +// 不出现魔法字符串,测试也能断言确切的线上形态。 const ( - // roleContextMarker opens the leading segment that declares the speaking role. + // roleContextMarker 开启声明发言角色的前导片段。 roleContextMarker = "[会话角色] " - // speakerSwitchMarker opens a boundary note inserted where the speaker of the - // conversation changes. + // speakerSwitchMarker 开启插在对话发言人 + // 发生变化处的边界说明。 speakerSwitchMarker = "[发言归属] " ) -// speakerOf returns the role id labelled as the speaker of m, or "" when the -// message carries no attribution. Only assistant messages are considered: a user -// message's SenderName is the third-party display name lane of design/16 §4.6 and -// has nothing to do with persona selection. +// speakerOf 返回被标注为 m 发言人的角色 id;该消息不带 +// 归属时返回 ""。只考虑 assistant 消息:用户 +// 消息的 SenderName 是 design/16 §4.6 的第三方显示名车道, +// 与人格选择无关。 func speakerOf(m model.Message) string { if m.Role != model.RoleAssistant { return "" @@ -54,10 +54,10 @@ func speakerOf(m model.Message) string { return strings.TrimSpace(m.SenderName) } -// anySpeakerLabel reports whether any message of the conversation carries a -// speaker label. When none does, attribution rendering is a complete no-op: a -// single-role conversation (or a caller that never labels its messages) keeps the -// exact pre-multi-character prompt it had before. +// anySpeakerLabel 报告对话中是否有任何消息携带 +// 发言人标签。都没有时,归属渲染是完全空操作:单角色对话 +// (或从不给消息打标签的调用方)保持 +// 多角色之前完全一样的提示词。 func anySpeakerLabel(msgs []model.Message) bool { for _, m := range msgs { if speakerOf(m) != "" { @@ -67,17 +67,17 @@ func anySpeakerLabel(msgs []model.Message) bool { return false } -// attributeConversation renders the role context for one model step. It returns -// the leading role declaration (ok = false when the conversation carries no -// speaker label at all), and the conversation with a boundary note inserted -// wherever the labelled speaker changes. +// attributeConversation 为一个模型步渲染角色上下文。它返回 +// 前导角色声明(对话完全不带发言人标签时 ok = false), +// 以及插入了边界说明的对话 —— 只要带标签的发言人 +// 发生变化就插入。 // -// The note announces the speaker for every labelled message that follows it until -// the next note, which is exactly the granularity the model needs to answer "第 N -// 条是谁说的" without touching a single message body. Rendering happens at request -// build time from the live slice, so a window that was truncated or compacted in -// the meantime can never end up with a message under the wrong speaker: a dropped -// note is regenerated, and no note claims a position it does not have. +// 该说明为其后每条带标签的消息宣告发言人,直到 +// 下一条说明,这正是模型回答「第 N +// 条是谁说的」所需的粒度,且不触碰任何消息正文。渲染发生在请求 +// 构建时、基于实时切片,因此中途被截断或压缩的窗口 +// 绝不会让某条消息落在错误的发言人之下:被丢弃的 +// 说明会重新生成,且没有说明会声称它不拥有的位置。 func (l *Loop) attributeConversation(ctx context.Context, msgs []model.Message) (model.Message, bool, []model.Message) { if !anySpeakerLabel(msgs) { return model.Message{}, false, msgs @@ -95,11 +95,11 @@ func (l *Loop) attributeConversation(ctx context.Context, msgs []model.Message) return l.roleContextMessage(current), true, out } -// roleContextMessage builds the leading segment that declares which role is -// answering, so a role that was just switched in has a strong sense of its own -// identity on top of its instructions (design/22 §7-5). It states the boundary -// rule for the notes below: an attributed utterance belongs to the role it was -// labelled with, not to the role answering now. +// roleContextMessage 构建声明哪个角色在作答的前导片段, +// 使刚被切换进来的角色在其指令之上对自己身份有强烈的 +// 感知(design/22 §7-5)。它陈述下文各条说明的边界 +// 规则:一段带归属的发言属于它被标注的角色, +// 而不属于当前作答的角色。 func (l *Loop) roleContextMessage(current string) model.Message { var b strings.Builder b.WriteString(roleContextMarker) @@ -114,10 +114,10 @@ func (l *Loop) roleContextMessage(current string) model.Message { return model.TextMessage(model.RoleSystem, b.String()) } -// speakerSwitchText builds the boundary note for one speaking role. A stretch of -// the answering role's own history is stated as "来自你自己" (so the model keeps -// continuity with what it said before a switch), every other role is stated as -// belonging to that role and explicitly not to the answering one. +// speakerSwitchText 为一个发言角色构建边界说明。 +// 作答角色自己的一段历史被表述为「来自你自己」(使模型与 +// 切换前说过的话保持连续),其他每个角色都被表述为 +// 属于该角色、且明确不属于当前作答者。 func (l *Loop) speakerSwitchText(personaID, current string) string { label := l.speakerLabel(personaID) if personaID != "" && personaID == current { @@ -126,9 +126,9 @@ func (l *Loop) speakerSwitchText(personaID, current string) string { return speakerSwitchMarker + "以下发言来自" + label + ",不是你说的。" } -// speakerLabel renders a role reference for the model: the role's display name -// with its id when a name resolver knows the role, the id alone otherwise. It -// never returns an empty string, so a note always names someone. +// speakerLabel 为模型渲染一个角色引用:名字解析器认识该角色时 +// 用其显示名加 id,否则只用 id。它 +// 绝不返回空字符串,因此说明总能指名道姓。 func (l *Loop) speakerLabel(personaID string) string { name := "" if l.personaName != nil { diff --git a/internal/runtime/loop/attribution_test.go b/internal/runtime/loop/attribution_test.go index 49f6614..864a299 100644 --- a/internal/runtime/loop/attribution_test.go +++ b/internal/runtime/loop/attribution_test.go @@ -10,22 +10,22 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// This file covers design/22 §7-5 from the model's side: a conversation whose -// assistant messages were spoken by different roles must reach the provider with -// that attribution visible, without a single character being added to a message -// body and without disturbing the assembled prompt's established order. +// 本文件从模型一侧覆盖 design/22 §7-5:一段其 assistant 消息 +// 由不同角色说出的对话,必须在归属可见的情况下抵达 provider, +// 且不向消息正文添加任何一个字符, +// 也不打乱组装好的提示词的既定顺序。 -// speakerMsg builds one conversation message labelled with the role that spoke -// it, exactly the way the gateway's transcript seeding labels an assistant turn -// (model.Message.SenderName carries the speaking role id). +// speakerMsg 构建一条标注了说出它的角色的对话消息, +// 与 gateway 的 transcript 播种标注 assistant 轮的方式完全一致 +// (model.Message.SenderName 携带发言角色 id)。 func speakerMsg(role model.Role, personaID, text string) model.Message { m := model.TextMessage(role, text) m.SenderName = personaID return m } -// attributedLoop builds a two-role loop: instructions say which role answered, and -// the name resolver mirrors the persona registry's id→display-name mapping. +// attributedLoop 构建一个双角色循环:指令说明是哪个角色作答, +// 名字解析器镜像 persona registry 的 id→显示名映射。 func attributedLoop(t *testing.T, ad model.Adapter) *Loop { t.Helper() xilian := personaAgent(t, "你是昔涟。", ad) @@ -39,8 +39,8 @@ func attributedLoop(t *testing.T, ad model.Adapter) *Loop { }) } -// seedMemory writes one persona-scoped memory item for personaID so the round's -// retrieval has something to inject. +// seedMemory 为 personaID 写入一条按人格作用域的记忆条目,使该轮的 +// 检索有东西可注入。 func seedMemory(t *testing.T, store *memory.Store, personaID, content string) { t.Helper() if _, err := store.Add(context.Background(), memory.MemoryItem{ @@ -51,7 +51,7 @@ func seedMemory(t *testing.T, store *memory.Store, personaID, content string) { } } -// systemTexts returns the text of every system message of req, in order. +// systemTexts 按顺序返回 req 中每条系统消息的文本。 func systemTexts(req model.Request) []string { out := make([]string, 0, len(req.Messages)) for _, m := range req.Messages { @@ -62,8 +62,8 @@ func systemTexts(req model.Request) []string { return out } -// indexOfSystemContaining returns the index of the first message whose text -// contains want, or -1. +// indexOfSystemContaining 返回文本包含 want 的第一条消息的索引, +// 没有则返回 -1。 func indexOfSystemContaining(req model.Request, want string) int { for i, m := range req.Messages { if strings.Contains(messageText(m), want) { @@ -73,12 +73,12 @@ func indexOfSystemContaining(req model.Request, want string) int { return -1 } -// TestAttribution_SpeakersReachTheModelRequest is the acceptance case of -// design/22 §7-5: the session now speaks as Y while the context still holds -// utterances by X. The request the adapter receives must (a) open with Y's own -// instructions, (b) declare which role is answering, (c) mark where the speaker -// changed so "第 N 条是谁说的" is decidable, and (d) leave every message body -// untouched — the attribution travels only in system messages. +// TestAttribution_SpeakersReachTheModelRequest 是 design/22 §7-5 的 +// 验收用例:会话现在以 Y 发言,而上下文中仍留有 +// X 说过的话。适配器收到的请求必须 (a) 以 Y 自己的 +// 指令开头,(b) 声明哪个角色在作答,(c) 标出发言人 +// 何处变化,使「第 N 条是谁说的」可判定,且 (d) 保持每条消息正文 +// 不动 —— 归属只在系统消息中传递。 func TestAttribution_SpeakersReachTheModelRequest(t *testing.T) { t.Parallel() ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "在呢"}}}} @@ -96,14 +96,14 @@ func TestAttribution_SpeakersReachTheModelRequest(t *testing.T) { } req := lastChatRequest(t, ad) - // (a) the role that answers now still owns the first system message. + // (a) 现在作答的角色仍然拥有第一条系统消息。 if got := messageText(req.Messages[0]); got != "你是由纪。" { t.Fatalf("first system = %q, want the bound role's instructions", got) } sys := systemTexts(req) - // instructions + the role-context segment + one boundary note per speaker - // stretch (X's two assistant turns are one stretch). + // instructions + 角色上下文片段 + 每个发言人片段一条边界说明 + // (X 的两个 assistant 轮是一个片段)。 if len(sys) != 4 { t.Fatalf("system messages = %d (%q), want 4 (instructions + role context + two speaker boundaries)", len(sys), sys) } @@ -128,8 +128,8 @@ func TestAttribution_SpeakersReachTheModelRequest(t *testing.T) { t.Fatalf("system[3] = %q, want Y's own boundary note naming the answering role", sys[3]) } - // (c) the boundary sits directly before the first utterance it covers, and the - // second X utterance inherits the same speaker without a second note. + // (c) 边界就位于它所覆盖的第一句话之前,且 + // 第二句 X 的话继承同一发言人,无需第二条说明。 firstX := indexOfSystemContaining(req, "我记住了") if firstX < 0 || req.Messages[firstX-1].Role != model.RoleSystem { t.Fatalf("X's first utterance at %d is not preceded by a boundary note (%+v)", firstX, req.Messages) @@ -137,14 +137,14 @@ func TestAttribution_SpeakersReachTheModelRequest(t *testing.T) { if idx := indexOfSystemContaining(req, "「昔涟」(xilian)"); idx > firstX { t.Fatalf("X boundary at %d must precede its utterance at %d", idx, firstX) } - // The answering role's own history is re-opened by its own boundary note. + // 作答角色自己的历史由它自己的边界说明重新开启。 self := indexOfSystemContaining(req, "我在呢") if self < 0 || !strings.Contains(messageText(req.Messages[self-1]), "来自你自己") { t.Fatalf("Y's utterance is not preceded by its self boundary: %+v", req.Messages) } - // (d) no body was rewritten: user text stays verbatim and carries no metadata, - // and the assistant's own words are unchanged. + // (d) 没有任何正文被改写:用户文本保持逐字且不携带元数据, + // 且 assistant 自己的话也未变。 var users []string for _, m := range req.Messages { if m.Role != model.RoleUser { @@ -167,10 +167,10 @@ func TestAttribution_SpeakersReachTheModelRequest(t *testing.T) { } } -// TestAssembleContext_AttributionKeepsBlockOrder pins the placement rule: the role -// declaration is added AFTER the established leading blocks (time, working memory, -// memory) and immediately before the conversation, so the memory/time block order -// and the instructions-first rule are untouched. +// TestAssembleContext_AttributionKeepsBlockOrder 钉住放置规则:角色 +// 声明被加在既定的前导块(时间、工作记忆、 +// 记忆)之后、紧邻对话之前,因此记忆/时间块的顺序 +// 与指令优先规则不受影响。 func TestAssembleContext_AttributionKeepsBlockOrder(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) @@ -217,10 +217,10 @@ func TestAssembleContext_AttributionKeepsBlockOrder(t *testing.T) { } } -// TestAttribution_UnlabeledConversationIsUntouched locks the backward-compatible -// half: a caller that labels nothing (every single-role path, IM, backthink, the -// autonomous passes) gets exactly the prompt it had before, with no role segment -// and no boundary notes. +// TestAttribution_UnlabeledConversationIsUntouched 钉住向后兼容的 +// 那一半:什么都不标注的调用方(每条单角色路径、IM、backthink、 +// 自主流程)拿到与从前完全一样的提示词,没有角色片段 +// 也没有边界说明。 func TestAttribution_UnlabeledConversationIsUntouched(t *testing.T) { t.Parallel() ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "done"}}}} diff --git a/internal/runtime/loop/capacity_ctx.go b/internal/runtime/loop/capacity_ctx.go index 80b787f..e384e6c 100644 --- a/internal/runtime/loop/capacity_ctx.go +++ b/internal/runtime/loop/capacity_ctx.go @@ -6,24 +6,24 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" ) -// capStateKey is the context key for the per-Run capacity-state accumulator. +// capStateKey 是每 Run capacity 状态累加器的 context key。 type capStateKey struct{} -// capStateAccum holds the mutable capacity state for one Run so the state -// progresses across a multi-step tool loop (quiet→awake→full) without leaking -// between concurrent runs. It lives on the context seeded at the top of Run. +// capStateAccum 持有一个 Run 的可变 capacity 状态,使该状态 +// 能跨多步工具循环演进(quiet→awake→full),且不在并发 run 之间 +// 泄漏。它挂在 Run 顶部播种的 context 上。 type capStateAccum struct { state capacity.State } -// withCapState returns a context carrying acc. +// withCapState 返回携带 acc 的 context。 func withCapState(ctx context.Context, acc *capStateAccum) context.Context { return context.WithValue(ctx, capStateKey{}, acc) } -// capState returns the accumulator on ctx, or a fresh quiet one when none is -// seeded (a defensive fallback for a caller that invokes toolSchemas outside -// Run, where the state is not meant to persist). +// capState 返回 ctx 上的累加器;未播种时返回一个全新的 quiet +// 累加器(为在 Run 之外调用 toolSchemas 的调用方提供的防御性兜底, +// 此处状态本就不应持久化)。 func capState(ctx context.Context) *capStateAccum { if acc, ok := ctx.Value(capStateKey{}).(*capStateAccum); ok { return acc diff --git a/internal/runtime/loop/capacity_l4_loop_test.go b/internal/runtime/loop/capacity_l4_loop_test.go index 63142c3..45f7212 100644 --- a/internal/runtime/loop/capacity_l4_loop_test.go +++ b/internal/runtime/loop/capacity_l4_loop_test.go @@ -10,9 +10,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// seedL4Habit writes a canonical intent record memory (the closed-loop write-back -// format produced by capacity.RecordIntent) recording that "搞一下这个部署" is a task -// for persona-a. +// seedL4Habit 写入一条规范的 intent record 记忆(由 +// capacity.RecordIntent 产出的闭环回写格式),记录 "搞一下这个部署" 是 +// persona-a 的任务。 func seedL4Habit(t *testing.T, store *memory.Store) { t.Helper() ctx := context.Background() @@ -26,9 +26,9 @@ func seedL4Habit(t *testing.T, store *memory.Store) { } } -// TestLoop_Capacity_L4_WakesOnLearnedHabit_OwnerEnv proves the L4 feed: with a -// retrieved user habit recording that the phrase is a task, an otherwise-undecided -// turn wakes in the owner-full env, and the model is advertised the awake tool set. +// TestLoop_Capacity_L4_WakesOnLearnedHabit_OwnerEnv 证明 L4 输入:在 +// 检索到记录该短语是任务的用户习惯后,一个原本未决的 +// turn 在 owner 全权限环境中唤醒,且模型被广告 awake 工具集。 func TestLoop_Capacity_L4_WakesOnLearnedHabit_OwnerEnv(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) @@ -53,15 +53,15 @@ func TestLoop_Capacity_L4_WakesOnLearnedHabit_OwnerEnv(t *testing.T) { t.Fatal("no streaming request captured") } set := toolSet(ad.streamReqs[0].Tools) - // L4 read the learned habit → awake → web-search is advertised (awake set). + // L4 读到了学到的习惯 → 唤醒 → 广告出 web-search(awake 集)。 if !hasAll(set, "terminal", "file", "web-search") { t.Fatalf("owner L4 tools = %v, want awake set incl web-search", toolNames(ad.streamReqs[0].Tools)) } } -// TestLoop_Capacity_L4_RestrictedEnv_NoOwnerHabitRead proves the L4 isolation red -// line: in a group/stranger context the loop passes no recall, so the decider falls -// back to L1/L2 and the owner's habit memory is never used to wake the intent. +// TestLoop_Capacity_L4_RestrictedEnv_NoOwnerHabitRead 证明 L4 隔离红线: +// 在群聊/陌生人上下文中循环不传任何召回,因此判定器回退到 +// L1/L2,主人的习惯记忆绝不被用来唤醒意图。 func TestLoop_Capacity_L4_RestrictedEnv_NoOwnerHabitRead(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) @@ -86,10 +86,10 @@ func TestLoop_Capacity_L4_RestrictedEnv_NoOwnerHabitRead(t *testing.T) { t.Fatal("no streaming request captured") } set := toolSet(ad.streamReqs[0].Tools) - // Restricted env → L4 silent → the turn stays undecided, and the design/11 §3 - // security gate hides every tool that is not marked AllowInGroup (terminal/file - // are unmarked). So the model sees nothing: web-search is absent (owner habit - // not read) and even the quiet-set tools are closed to a group session. + // 受限环境 → L4 静默 → 该 turn 保持未决,且 design/11 §3 + // 安全门隐藏所有未标记 AllowInGroup 的工具(terminal/file + // 未标记)。因此模型什么也看不到:web-search 缺席(主人的习惯 + // 未被读取),连 quiet 集工具也对群聊会话关闭。 if len(set) != 0 { t.Fatalf("restricted tools = %v, want none (security gate closes unmarked tools)", toolNames(ad.streamReqs[0].Tools)) } diff --git a/internal/runtime/loop/capacity_loop_test.go b/internal/runtime/loop/capacity_loop_test.go index 051da44..923bfcb 100644 --- a/internal/runtime/loop/capacity_loop_test.go +++ b/internal/runtime/loop/capacity_loop_test.go @@ -9,9 +9,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" ) -// capacityToolSet builds a dispatcher over the four representative tools used to -// verify the allowed-set filter: two quiet tools, one awake-only, and one that is -// never allowed in quiet/awake. +// capacityToolSet 为验证允许集过滤所用的四个代表性工具 +// 构建一个 dispatcher:两个 quiet 工具、一个仅 awake 的, +// 以及一个在 quiet/awake 下永不被允许的。 func capacityToolSet(t *testing.T) *tool.Dispatcher { t.Helper() return dispatcherWith(t, @@ -22,9 +22,9 @@ func capacityToolSet(t *testing.T) *tool.Dispatcher { ) } -// toolNames returns the set of tool names advertised in specs. The registry -// List iterates a map, so registration order is not preserved; compare with a -// set (via toolSet) rather than a slice for order-independent assertions. +// toolNames 返回 specs 中广告出的工具名集合。registry 的 +// List 遍历 map,因此注册顺序不保留;做与顺序无关的断言时 +// 应与集合(经 toolSet)比较,而不是与切片比较。 func toolNames(specs []model.ToolSpec) []string { out := make([]string, 0, len(specs)) for _, s := range specs { @@ -50,9 +50,9 @@ func hasAll(set map[string]bool, names ...string) bool { return true } -// TestLoop_Capacity_QuietHidesHeavierTools verifies that with an enabled capacity -// decider a chitchat turn advertises only the quiet allowed set to the model -// (terminal + file, not web-search/json) on the streaming path. +// TestLoop_Capacity_QuietHidesHeavierTools 验证启用 capacity +// 判定器时,闲聊 turn 在流式路径上只向模型广告 quiet 允许集 +// (terminal + file,不含 web-search/json)。 func TestLoop_Capacity_QuietHidesHeavierTools(t *testing.T) { t.Parallel() td := capacityToolSet(t) @@ -80,8 +80,8 @@ func TestLoop_Capacity_QuietHidesHeavierTools(t *testing.T) { } } -// TestLoop_Capacity_WakeExposesWebSearch verifies a task turn exposes the awake -// allowed set (adds web-search) to the model. +// TestLoop_Capacity_WakeExposesWebSearch 验证任务 turn 向模型 +// 暴露 awake 允许集(增加 web-search)。 func TestLoop_Capacity_WakeExposesWebSearch(t *testing.T) { t.Parallel() td := capacityToolSet(t) @@ -106,8 +106,8 @@ func TestLoop_Capacity_WakeExposesWebSearch(t *testing.T) { } } -// TestLoop_Capacity_NilKeepsAllTools verifies the pre-existing behavior: without -// a capacity decider every registered tool is advertised (no filtering). +// TestLoop_Capacity_NilKeepsAllTools 验证既有行为:没有 +// capacity 判定器时,每个已注册工具都被广告(不过滤)。 func TestLoop_Capacity_NilKeepsAllTools(t *testing.T) { t.Parallel() td := capacityToolSet(t) @@ -126,8 +126,8 @@ func TestLoop_Capacity_NilKeepsAllTools(t *testing.T) { } } -// TestLoop_Capacity_EmitsAwakenEvent verifies a state change surfaces a -// capacity_awaken event through the OnCapacity hook on the streaming path. +// TestLoop_Capacity_EmitsAwakenEvent 验证状态变化在流式路径上 +// 经 OnCapacity 钩子浮出一个 capacity_awaken 事件。 func TestLoop_Capacity_EmitsAwakenEvent(t *testing.T) { t.Parallel() td := capacityToolSet(t) diff --git a/internal/runtime/loop/checkpoint.go b/internal/runtime/loop/checkpoint.go index 2415467..5806064 100644 --- a/internal/runtime/loop/checkpoint.go +++ b/internal/runtime/loop/checkpoint.go @@ -11,29 +11,29 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist" ) -// checkpointStore is the minimal surface the loop needs to persist a durable -// snapshot and resume an interrupted run (design §1 durable execution). -// *persist.Store satisfies it in production; tests substitute a fake to assert -// the resume path without a real database. +// checkpointStore 是循环持久化持久快照并恢复被中断 +// run 所需的最小表面(design §1 durable execution)。 +// 生产环境由 *persist.Store 满足;测试替换为假实现,以在 +// 没有真实数据库的情况下断言恢复路径。 type checkpointStore interface { - // Save upserts a checkpoint. + // Save 插入或更新一个 checkpoint。 Save(ctx context.Context, cp *persist.Checkpoint) error - // LoadLatest returns the most recently updated checkpoint for agentID, or - // ErrNotFound when the agent has none. + // LoadLatest 返回 agentID 最近更新的 checkpoint; + // 该 agent 没有时返回 ErrNotFound。 LoadLatest(ctx context.Context, agentID string) (*persist.Checkpoint, error) } -// checkpointState is the durable snapshot persisted at every step. Carrying the -// message window and the step index lets a long-running task be resumed from the -// latest checkpoint without losing context (design §1 durable execution). +// checkpointState 是每步持久化的持久快照。携带消息 +// 窗口与步索引,使长任务能从最新 checkpoint 恢复而不 +// 丢失上下文(design §1 durable execution)。 type checkpointState struct { Step int `json:"step"` Messages []model.Message `json:"messages"` StopReason string `json:"stop_reason,omitempty"` } -// saveCheckpoint persists a snapshot at step. It never fails the run: a nil store -// is a no-op and a marshal/save error is logged. +// saveCheckpoint 在 step 处持久化一个快照。它绝不让 run 失败: +// store 为 nil 时为空操作,marshal/save 出错则记录日志。 func (l *Loop) saveCheckpoint(ctx context.Context, sessionID string, step int, messages []model.Message, stopReason string) { if l.checkpoint == nil { return @@ -59,19 +59,19 @@ func (l *Loop) saveCheckpoint(ctx context.Context, sessionID string, step int, m } } -// loadCheckpoint resumes an interrupted run from the session's latest durable -// checkpoint (design §1 durable execution). It returns the restored message -// window, the next loop step to run (the checkpoint step + 1) and whether a -// resumable checkpoint was found. When no checkpoint exists, or the latest one -// reflects a completed/blocked run, it returns the caller's messages with step 1 -// so the loop starts fresh. +// loadCheckpoint 从该会话最新的持久 checkpoint 恢复一个被中断的 +// run(design §1 durable execution)。它返回恢复后的消息 +// 窗口、下一个要运行的循环步(checkpoint 步 + 1),以及 +// 是否找到了可恢复的 checkpoint。不存在 checkpoint,或最新的一个 +// 反映的是已完成/被阻塞的 run 时,它返回调用方的消息并置步为 1, +// 使循环从新开始。 // -// Restoration is idempotent: the checkpoint captures the message window exactly as -// it stood at the end of the last completed step (assistant tool calls plus their -// results are already fused in), so resuming at the next step rebuilds the model -// context from history and never re-executes a completed step's tool call nor -// regenerates a finished answer. Loading is read-only — a resume never deletes the -// checkpoint, so every step's snapshot stays queryable. +// 恢复是幂等的:checkpoint 精确保存了上一个已完成步结束时 +// 的消息窗口(assistant 工具调用及其结果已融合在内), +// 因此从下一步恢复会由历史重建模型 +// 上下文,绝不重跑已完成步的工具调用,也不 +// 重新生成已完成的答案。加载是只读的 —— 恢复绝不删除 +// checkpoint,因此每一步的快照始终可查询。 func (l *Loop) loadCheckpoint(ctx context.Context, sessionID string, messages []model.Message) ([]model.Message, int, bool) { if l.checkpoint == nil { return messages, 1, false @@ -96,16 +96,16 @@ func (l *Loop) loadCheckpoint(ctx context.Context, sessionID string, messages [] return st.Messages, cp.Step + 1, true } -// resumableStopReason reports whether a checkpoint whose last step ended with the -// given stop reason represents an in-flight run worth resuming. Only a -// mid-tool-loop step is genuinely "interrupted": the loop folded the tool results -// into the window and was about to continue, so picking up at the next step -// rebuilds context without duplicating work. A checkpoint carrying a final answer, -// a budget exhaustion, a safety-net termination or a guardrail denial is a -// completed/blocked run — resuming it would regenerate an already-produced answer -// (a duplicate side effect) — so those fall through to a fresh start (a caller -// opening a genuinely new conversation uses a distinct sessionID, which misses -// LoadLatest anyway). +// resumableStopReason 报告最后一个步以上述 stop reason 结束的 +// checkpoint 是否代表一个值得恢复的在途 run。只有 +// 工具循环中途的步才真正算「被中断」:循环已把工具结果 +// 并入窗口并即将继续,因此从下一步接上 +// 会重建上下文而不重复工作。携带最终答案、 +// 预算耗尽、安全网终止或护栏拒绝的 checkpoint 是 +// 已完成/被阻塞的 run —— 恢复它会重新生成已产出的答案 +// (重复副作用)—— 因此这些一律落到全新开始(打开 +// 真正新会话的调用方使用不同的 sessionID,本就错过 +// LoadLatest)。 func resumableStopReason(stopReason string) bool { switch stopReason { case "", agent.StopReasonToolCalls: diff --git a/internal/runtime/loop/compactor.go b/internal/runtime/loop/compactor.go index d1553a9..9c92a9d 100644 --- a/internal/runtime/loop/compactor.go +++ b/internal/runtime/loop/compactor.go @@ -9,70 +9,70 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// Compaction tuning constants. These bound the compactor so it stays -// deterministic and offline: the summary is capped, the recent tail is a share -// of the budget, and very short conversations are left alone even when they -// exceed the budget (design §1.3 上下文溢出压缩). +// 压缩调优常量。它们界定 compactor,使其保持确定性与 +// 离线:摘要被限额、近期尾部占预算的一个比例, +// 且极短的对话即使超出预算也原样不动 +// (design §1.3 上下文溢出压缩)。 const ( - // DefaultMaxContextTokens is the context budget a caller can assign to - // Options.MaxContextTokens to enable overflow compaction (design §1.3). + // DefaultMaxContextTokens 是调用方可赋给 + // Options.MaxContextTokens 以启用溢出压缩的上下文预算(design §1.3)。 DefaultMaxContextTokens = 32768 - // keepRecentFraction is the share of the budget reserved for the recent tail - // during compaction; the remainder is left for the summary message and prompt. + // keepRecentFraction 是压缩时为近期尾部保留的 + // 预算比例;余下部分留给摘要消息与提示词。 keepRecentFraction = 0.5 - // minKeepTokens is the floor for the recent-tail budget so a tiny - // MaxContextTokens does not force every turn into the summary. + // minKeepTokens 是近期尾部预算的下限,使极小的 + // MaxContextTokens 不会把每个 turn 都逼进摘要。 minKeepTokens = 128 - // summaryMarker tags a compaction summary system message so the compactor can - // recognize an existing summary prefix and preserve (merge into) it rather - // than re-summarize it on a later compaction. + // summaryMarker 标记压缩摘要系统消息,使 compactor 能 + // 识别已存在的摘要前缀并保留(并入)它, + // 而不是在后续压缩中重新摘要。 summaryMarker = "[前文摘要 · history summary]" - // maxSummaryChars caps the compaction summary so repeated compactions cannot - // inflate the summary without bound (design §1.3 摘要容量上限). + // maxSummaryChars 限制压缩摘要,使反复压缩无法 + // 无界地膨胀摘要(design §1.3 摘要容量上限)。 maxSummaryChars = 4000 - // minCompactMessages is the smallest conversation the compactor will touch; a - // shorter one is left alone even when it is over budget. + // minCompactMessages 是 compactor 会触碰的最小对话; + // 更短的即使超出预算也原样不动。 minCompactMessages = 3 - // messageOverheadTokens is the per-message token overhead charged for the role - // and separator tokens the provider adds around each message. + // messageOverheadTokens 是每条消息的 token 开销,对应 provider + // 在每条消息周围添加的角色与分隔符 token。 messageOverheadTokens = 4 - // imageTokens is the rough token charge for a single image part. + // imageTokens 是单个图像部件的粗略 token 计数。 imageTokens = 1000 - // charsPerToken is the character-to-token divisor used by the offline estimate. + // charsPerToken 是离线估算使用的字符到 token 除数。 charsPerToken = 4 - // summarySampleChars bounds the per-message text sampled into a naive summary. + // summarySampleChars 限制被抽样进朴素摘要的每条消息文本长度。 summarySampleChars = 120 ) -// Compactor compacts an over-budget conversation by summarizing older turns into -// a single leading summary message while preserving the recent tail — including -// the trailing tool results — unmodified (design §1.3, aligned with OpenClaw's -// context-engine). It is injected through Options.Compactor so a caller can swap -// in an LLM-backed summarizer; the loop ships a structural default that never -// calls a model. +// Compactor 通过把更早的轮次摘要成单条前导摘要消息来压缩 +// 超预算的对话,同时原样保留近期尾部 —— 包括 +// 尾部的工具结果(design §1.3,与 OpenClaw 的 +// context-engine 对齐)。它经 Options.Compactor 注入,使调用方能 +// 换成 LLM 支持的摘要器;循环自带一个绝不调用 +// 模型的结构化默认实现。 type Compactor interface { - // Compact returns messages with older turns compressed into a leading summary - // when the estimated tokens exceed budget. It returns the input unchanged when - // the messages already fit within budget or there is nothing safe to - // summarize. ctx is the abort signal: a canceled context stops compaction and - // returns a wrapped context.Canceled so the caller can bail out. + // Compact 在估算 token 超过 budget 时返回把更早轮次压缩为 + // 一条前导摘要的 messages。消息本来就在预算内,或 + // 没有可安全摘要的内容时,它原样返回输入。 + // ctx 是中止信号:被取消的 context 停止压缩并 + // 返回包装过的 context.Canceled,使调用方能退出。 Compact(ctx context.Context, messages []model.Message, budget int) ([]model.Message, error) } -// defaultCompactor is the built-in structural compactor. It is stateless and -// safe for concurrent use. +// defaultCompactor 是内置的结构化 compactor。它无状态, +// 且并发安全。 type defaultCompactor struct{} -// Compact implements Compactor. +// Compact 实现 Compactor。 func (defaultCompactor) Compact(ctx context.Context, messages []model.Message, budget int) ([]model.Message, error) { if ctx == nil { ctx = context.Background() @@ -83,8 +83,8 @@ func (defaultCompactor) Compact(ctx context.Context, messages []model.Message, b return compactMessages(ctx, messages, budget) } -// compactMessages is the shared compaction implementation. It never mutates the -// input slices; a compacted result is always freshly allocated. +// compactMessages 是共享的压缩实现。它绝不改写 +// 输入切片;压缩结果始终是新分配的。 func compactMessages(ctx context.Context, messages []model.Message, budget int) ([]model.Message, error) { if budget <= 0 || len(messages) < minCompactMessages { return messages, nil @@ -97,7 +97,7 @@ func compactMessages(ctx context.Context, messages []model.Message, budget int) keepTokens := max(int(float64(budget)*keepRecentFraction), minKeepTokens) keepStart := findKeepStart(messages, prefixEnd, keepTokens) if keepStart <= prefixEnd { - // Everything is being kept; there is no safe prefix to summarize. + // 全部内容都被保留;没有可安全摘要的前缀。 return messages, nil } @@ -125,9 +125,9 @@ func compactMessages(ctx context.Context, messages []model.Message, budget int) return out, nil } -// summaryPrefixEnd returns the count of leading compaction-summary messages, -// which are never re-summarized. It is 0 (no summary) or 1 (a single summary -// prefix), since compaction always emits exactly one leading summary. +// summaryPrefixEnd 返回前导压缩摘要消息的数量, +// 它们绝不再次被摘要。取值为 0(无摘要)或 1(单条摘要 +// 前缀),因为压缩始终只产出一条前导摘要。 func summaryPrefixEnd(messages []model.Message) int { if len(messages) == 0 || messages[0].Role != model.RoleSystem { return 0 @@ -138,12 +138,12 @@ func summaryPrefixEnd(messages []model.Message) int { return 0 } -// findKeepStart returns the index of the first message to keep given that the -// recent tail should fit within keepTokens. It always keeps whole turns: a turn -// starts at a user message and runs to the next user message. It scores turns -// from the back, so the most recent turns survive and a trailing tool result is -// never sliced apart. When no user turn exists (unusual), it keeps nothing extra -// and returns from, so nothing is summarized. +// findKeepStart 在近期尾部应装入 keepTokens 的前提下, +// 返回第一条要保留的消息索引。它始终保留完整的 turn:一个 turn +// 从用户消息开始,直到下一条用户消息。它从后往前 +// 给 turn 计分,因此最近的 turn 得以存活,尾部的工具结果 +// 绝不被切开。不存在用户 turn 时(少见),它不额外保留任何东西 +// 并从 from 返回,因此什么也不被摘要。 func findKeepStart(messages []model.Message, from, keepTokens int) int { n := len(messages) turns := make([]int, 0, 8) @@ -173,10 +173,10 @@ func findKeepStart(messages []model.Message, from, keepTokens int) int { return keptStart } -// estimateContextTokens returns a conservative token estimate for messages. It is -// a heuristic (chars ÷ charsPerToken with a per-message and per-image overhead) -// so it stays dependency-free and offline, but it is monotonic enough to drive -// the compact decision. +// estimateContextTokens 返回 messages 的保守 token 估算。它是 +// 启发式(字符 ÷ charsPerToken,加上每条消息与每个图像的开销), +// 因此无依赖且离线,但单调性足够驱动 +// 压缩决策。 func estimateContextTokens(messages []model.Message) int { total := 0 for _, m := range messages { @@ -193,9 +193,9 @@ func estimateContextTokens(messages []model.Message) int { return total } -// estimateStringTokens is the character-level token estimate for one text string, -// weighting wide (CJK) runes double and dividing by charsPerToken. An empty string -// costs nothing; a non-empty one costs at least one token. +// estimateStringTokens 是单个文本字符串的字符级 token 估算, +// 把宽字符(CJK)rune 计为双倍权重再除以 charsPerToken。空字符串 +// 不花费任何 token;非空至少花费一个 token。 func estimateStringTokens(s string) int { if s == "" { return 0 @@ -214,10 +214,10 @@ func estimateStringTokens(s string) int { return t } -// buildSummary renders a compact structural summary of the given messages: a role -// distribution plus the most recent user and assistant text. It is the default -// (offline, no-model) summarizer; a caller that wants a semantic digest injects -// its own Compactor instead. +// buildSummary 渲染给定 messages 的紧凑结构化摘要:角色 +// 分布,加上最近的用户与 assistant 文本。它是默认的 +// (离线、不调模型)摘要器;想要语义化摘要的调用方 +// 改为注入自己的 Compactor。 func buildSummary(messages []model.Message) string { if len(messages) == 0 { return "" @@ -260,8 +260,8 @@ func buildSummary(messages []model.Message) string { return b.String() } -// capSummary truncates s to at most max runes, appending a "..." marker when it -// had to cut. It is rune-safe so it never slices a multi-byte rune in half. +// capSummary 把 s 截断到至多 max 个 rune,需要裁剪时追加 "..." 标记。 +// 它是 rune 安全的,因此绝不把多字节 rune 切成两半。 func capSummary(s string, max int) string { runes := []rune(s) if len(runes) <= max { @@ -273,7 +273,7 @@ func capSummary(s string, max int) string { return string(runes[:max-3]) + "..." } -// truncateRunes shortens s to at most n runes, Unicode-safe. +// truncateRunes 把 s 缩短到至多 n 个 rune,Unicode 安全。 func truncateRunes(s string, n int) string { runes := []rune(s) if len(runes) <= n { diff --git a/internal/runtime/loop/compactor_test.go b/internal/runtime/loop/compactor_test.go index 4874a9c..b6c05df 100644 --- a/internal/runtime/loop/compactor_test.go +++ b/internal/runtime/loop/compactor_test.go @@ -11,9 +11,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// buildTurns builds a multi-turn conversation: n user/assistant turns followed by -// a trailing tool result, each message padded to around pad chars so the token -// estimate can be pushed over a small budget. +// buildTurns 构建一段多轮对话:n 个 user/assistant 轮,后接 +// 一个尾部工具结果,每条消息填充到约 pad 个字符,以便把 token +// 估算推过一个小预算。 func buildTurns(n, pad int, withTool bool) []model.Message { msgs := make([]model.Message, 0, n*2+1) for i := 0; i < n; i++ { @@ -58,17 +58,17 @@ func TestCompact_OverBudget_PreservesTrailingToolResult(t *testing.T) { if len(got) >= len(msgs) { t.Fatalf("compaction should shrink the conversation: got %d, want < %d", len(got), len(msgs)) } - // A leading summary system message marks the compaction. + // 一条前导摘要系统消息标记该压缩。 if got[0].Role != model.RoleSystem || !strings.HasPrefix(messageText(got[0]), summaryMarker) { t.Fatalf("first message is not a compaction summary: %+v", got[0]) } - // The trailing tool result survives at the very end, unmodified. + // 尾部工具结果原样存活在最后。 last := got[len(got)-1] if last.Role != model.RoleTool || !strings.Contains(last.Content[0].Text, "tool-result") { t.Fatalf("trailing tool result was not preserved: %+v", last) } - // Whenever a summary is emitted, the first kept message is a whole-turn - // boundary (a user message), so compaction never splits a turn. + // 只要发出摘要,第一条保留的消息就是一个整 turn + // 边界(用户消息),因此压缩绝不切开一个 turn。 if got[1].Role != model.RoleUser { t.Fatalf("first kept message role = %q, want a turn-start user message", got[1].Role) } @@ -76,9 +76,9 @@ func TestCompact_OverBudget_PreservesTrailingToolResult(t *testing.T) { func TestCompact_NeverSplitsTurn_MidConversation(t *testing.T) { t.Parallel() - // Two turns of very different sizes: the second turn is huge, the first is - // small. Compaction must keep the whole huge last turn and summarize the - // small earlier turn(s), not slice the last turn apart. + // 两个大小悬殊的 turn:第二个极大,第一个 + // 很小。压缩必须保留整个巨大的最后一个 turn,并摘要 + // 更早的小 turn,而不是把最后一个 turn 切开。 big := strings.Repeat("b", 5000) msgs := []model.Message{ model.TextMessage(model.RoleUser, "question one"), @@ -96,8 +96,8 @@ func TestCompact_NeverSplitsTurn_MidConversation(t *testing.T) { if got[0].Role != model.RoleSystem { t.Fatalf("expected a leading summary, got %+v", got[0]) } - // The kept region begins at the second user turn and its assistant reply are - // both intact. + // 保留区域从第二个用户 turn 开始,其 assistant 回复 + // 都完整无损。 if got[1].Role != model.RoleUser || got[1].Content[0].Text != "question two" { t.Fatalf("kept region should start a whole turn: %+v", got[1]) } @@ -108,8 +108,8 @@ func TestCompact_NeverSplitsTurn_MidConversation(t *testing.T) { func TestCompact_SummaryCap(t *testing.T) { t.Parallel() - // A huge prefix so the naive summary would try to sample long text, but each - // sampled message is truncated; verify the capped length stays bounded. + // 一个巨大的前缀,使朴素摘要会试图抽样长文本,但每条 + // 被抽样的消息都被截断;验证被限额后的长度保持有界。 msgs := buildTurns(8, 5000, false) got, err := (defaultCompactor{}).Compact(context.Background(), msgs, 5000) if err != nil { @@ -131,7 +131,7 @@ func TestCompact_ExistingSummaryMerged(t *testing.T) { if err != nil { t.Fatalf("Compact: %v", err) } - // Only one leading summary remains and it merges the prior summary. + // 只剩一条前导摘要,且它合并了此前的摘要。 if got[0].Role != model.RoleSystem { t.Fatalf("expected a leading summary") } diff --git a/internal/runtime/loop/flow.go b/internal/runtime/loop/flow.go index ef7ba63..d2901e2 100644 --- a/internal/runtime/loop/flow.go +++ b/internal/runtime/loop/flow.go @@ -8,26 +8,26 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) -// graphUpdateSubject is the bus subject the FlowPublisher publishes each round's -// live flow graph on. The gateway hub subscribes to ws.> and relays it as the -// WS event type graph_update (design/19 §1). +// graphUpdateSubject 是 FlowPublisher 发布每轮实时 +// flow 图所用的 bus subject。gateway hub 订阅 ws.> 并将其 +// 作为 WS 事件类型 graph_update 转发(design/19 §1)。 const graphUpdateSubject = "ws.graph_update" -// allowedSessions is the predicate signature used to gate which sessions publish -// flow graphs (the owner's web sessions), so IM/stranger/sub-agent turns never -// leak into the admin panel. A nil predicate allows every session. +// allowedSessions 是用于控制哪些会话可发布 +// flow 图的谓词签名(主人的 web 会话),使 IM/陌生人/子代理轮次绝不 +// 泄露进管理面板。谓词为 nil 时允许所有会话。 type allowedSessions func(sessionID string) bool -// FlowPublisher accumulates a per-session flow graph from the loop's trace spans -// and publishes ws.graph_update as each span starts/finishes, giving the frontend -// a realtime input→think→tool→output graph (design/19 §1). It is nil-tolerant -// (a nil publisher is a no-op) and safe for concurrent use; session graphs are -// serialized under one mutex because parallel tool spans finish concurrently. +// FlowPublisher 从循环的 trace span 累积每会话 flow 图, +// 并在每个 span 开始/结束时发布 ws.graph_update,给前端 +// 一张实时的 input→think→tool→output 图(design/19 §1)。它容忍 nil +// (nil 发布者为空操作)且并发安全;会话图 +// 在同一把互斥锁下串行化,因为并行的工具 span 会并发完成。 // -// Ownership: one session owns one live round. A Run that declares itself a branch -// of another session (loop.WithFlowBranch) never calls StartRound/EndRound and -// publishes its spans under the owning session instead, so a sub-agent renders as -// a swimlane of the parent's graph rather than as a second graph (design/19 §2). +// 归属:一个会话拥有一个实时轮次。声明自己是另一会话分支的 +// Run(loop.WithFlowBranch)绝不调用 StartRound/EndRound, +// 而是把其 span 发布到所属会话下,因此子代理渲染为 +// 父图的一条泳道,而不是第二张图(design/19 §2)。 type FlowPublisher struct { mu sync.Mutex publish func(subject string, payload any) @@ -36,10 +36,10 @@ type FlowPublisher struct { rounds map[string]int } -// NewFlowPublisher builds a FlowPublisher. publish, when non-nil, is called with -// graphUpdateSubject and a *trace.FlowGraph snapshot on every span transition. -// allow, when non-nil, gates which session ids are published (returning false -// drops the session entirely). Both may be nil (a nil publish is a no-op). +// NewFlowPublisher 构建一个 FlowPublisher。publish 非 nil 时,会在 +// 每次 span 状态迁移时以 graphUpdateSubject 与 *trace.FlowGraph +// 快照调用。allow 非 nil 时,控制哪些 session id 可发布(返回 +// false 则整个会话被丢弃)。两者都可以为 nil(nil publish 为空操作)。 func NewFlowPublisher(publish func(subject string, payload any), allow func(sessionID string) bool) *FlowPublisher { return &FlowPublisher{ publish: publish, @@ -49,9 +49,9 @@ func NewFlowPublisher(publish func(subject string, payload any), allow func(sess } } -// StartRound begins a fresh graph for sessionID, seeding an input node from the -// user text and publishing the initial frame. It resets the session's previous -// round, so each user turn is its own graph. +// StartRound 为 sessionID 开始一张新图,用用户文本 +// 播种一个 input 节点并发布初始帧。它重置该会话的上一 +// 轮次,因此每个用户 turn 都是自己的一张图。 func (p *FlowPublisher) StartRound(sessionID, inputText string) { if sessionID == "" || !p.allowed(sessionID) { return @@ -67,9 +67,9 @@ func (p *FlowPublisher) StartRound(sessionID, inputText string) { p.publishLocked(g) } -// EndRound finalizes the round by seeding an output node from the reply and -// publishing the final frame. It keeps the graph around so a client that misses -// the final frame can replay the last snapshot on refresh. +// EndRound 用回复播种一个 output 节点并发布最终帧, +// 以此收尾该轮次。它保留该图,使错过最终帧的客户端 +// 刷新时能重放最后一份快照。 func (p *FlowPublisher) EndRound(sessionID, outputText string) { if sessionID == "" || !p.allowed(sessionID) { return @@ -84,8 +84,8 @@ func (p *FlowPublisher) EndRound(sessionID, outputText string) { p.publishLocked(g) } -// SpanStarted records a running placeholder node for a span that just began. -// State-transition spans are omitted by the graph builder. +// SpanStarted 为一个刚开始的 span 记录一个运行中的占位 +// 节点。state_transition span 会被图构建器省略。 func (p *FlowPublisher) SpanStarted(sessionID string, sp *trace.Span) { if sp == nil || sessionID == "" || !p.allowed(sessionID) { return @@ -101,8 +101,8 @@ func (p *FlowPublisher) SpanStarted(sessionID string, sp *trace.Span) { } } -// SpanFinished folds a just-finished span into the session graph as a final node -// (status done/error) and publishes. It must be called after the span's Finish. +// SpanFinished 把一个刚结束的 span 作为最终节点(状态 done/error) +// 并入会话图并发布。必须在 span 的 Finish 之后调用。 func (p *FlowPublisher) SpanFinished(sessionID string, sp *trace.Span) { if sp == nil || sessionID == "" || !p.allowed(sessionID) { return @@ -117,7 +117,7 @@ func (p *FlowPublisher) SpanFinished(sessionID string, sp *trace.Span) { p.publishLocked(g) } -// publishLocked publishes a snapshot of g. Callers must hold p.mu. +// publishLocked 发布 g 的一份快照。调用方必须持有 p.mu。 func (p *FlowPublisher) publishLocked(g *trace.RunGraph) { if p.publish == nil { return @@ -125,7 +125,7 @@ func (p *FlowPublisher) publishLocked(g *trace.RunGraph) { p.publish(graphUpdateSubject, g.Snapshot()) } -// allowed reports whether a session's graphs should be published. +// allowed 报告某会话的图是否应当发布。 func (p *FlowPublisher) allowed(sessionID string) bool { if p.allow == nil { return true @@ -133,8 +133,8 @@ func (p *FlowPublisher) allowed(sessionID string) bool { return p.allow(sessionID) } -// lastUserText returns the text of the newest user message in messages, used to -// label the round's input node. It returns "" when no user text is present. +// lastUserText 返回 messages 中最新用户消息的文本,用于 +// 标注该轮的 input 节点。没有用户文本时返回 ""。 func lastUserText(messages []model.Message) string { for i := len(messages) - 1; i >= 0; i-- { if messages[i].Role != model.RoleUser { diff --git a/internal/runtime/loop/flow_branch.go b/internal/runtime/loop/flow_branch.go index be84c84..44b416d 100644 --- a/internal/runtime/loop/flow_branch.go +++ b/internal/runtime/loop/flow_branch.go @@ -2,22 +2,22 @@ package loop import "context" -// flowBranchKey is the context key marking a Run as a branch of another -// session's flow graph. It is set by the orchestration layer (through -// WithFlowBranch) before it drives a sub-agent's Run. +// flowBranchKey 是标记某个 Run 为另一会话 flow 图分支的 context key。 +// 它由 orchestration 层(经 WithFlowBranch)在驱动 +// 子代理 Run 之前设置。 type flowBranchKey struct{} -// WithFlowBranch returns ctx marking the current Run as a branch: its spans fold -// into parentSession's live flow graph and the Run neither starts nor ends a -// round of its own — the owning session's round does. It is how the orchestration -// layer attributes a sub-agent's work to the session that spawned it, so parallel -// sub-agents render as swimlanes of one graph instead of replacing the frontend's -// single flowGraph with a graph of their own (design/19 §2). +// WithFlowBranch 返回 ctx,把当前 Run 标记为分支:它的 span 并入 +// parentSession 的实时 flow 图,且该 Run 自身既不开始也不结束 +// 轮次 —— 由所属会话的轮次负责。orchestration 层正是靠它把 +// 子代理的工作归属到派生它的会话,使并行的 +// 子代理渲染为同一张图的泳道,而不是用各自的图 +// 替换前端唯一的 flowGraph(design/19 §2)。 // -// An empty parentSession still marks the Run as a branch: it owns no round and -// publishes nothing. That is the state of a background sub-agent nobody attributed -// to a session, and it must not silently become a graph of its own. A nil ctx is -// returned unchanged. +// 空的 parentSession 仍把该 Run 标记为分支:它不拥有轮次、 +// 不发布任何东西。这正是无人归属到会话的后台子代理的 +// 状态,它不得悄悄变成属于自己的一张图。nil ctx +// 原样返回。 func WithFlowBranch(ctx context.Context, parentSession string) context.Context { if ctx == nil { return ctx @@ -25,9 +25,9 @@ func WithFlowBranch(ctx context.Context, parentSession string) context.Context { return context.WithValue(ctx, flowBranchKey{}, parentSession) } -// flowBranch reports the parent session recorded by WithFlowBranch and whether the -// Run is a branch at all. A non-branch Run owns its own round and publishes under -// its own session id. +// flowBranch 报告 WithFlowBranch 记录的父会话以及 +// 该 Run 是否本就是分支。非分支的 Run 拥有自己的轮次并在 +// 自己的 session id 下发布。 func flowBranch(ctx context.Context) (string, bool) { if ctx == nil { return "", false @@ -40,9 +40,9 @@ func flowBranch(ctx context.Context) (string, bool) { return s, true } -// flowOwner returns the session whose flow graph should receive this Run's spans: -// the branch parent when the Run is a sub-agent branch (empty when the branch is -// unattributed, which suppresses publication), otherwise the Run's own sessionID. +// flowOwner 返回应当接收该 Run span 的 flow 图所属会话: +// 当该 Run 是子代理分支时取分支父级(分支未归属时为空, +// 此时抑制发布),否则取该 Run 自身的 sessionID。 func flowOwner(ctx context.Context, sessionID string) string { if parent, isBranch := flowBranch(ctx); isBranch { return parent @@ -50,14 +50,14 @@ func flowOwner(ctx context.Context, sessionID string) string { return sessionID } -// FlowOwnerFromContext returns the session whose flow graph owns work started -// from ctx: the branch parent when ctx runs inside a sub-agent branch, otherwise -// the session id the enclosing Run was driven with (empty when neither is set). +// FlowOwnerFromContext 返回拥有从 ctx 启动的工作的 flow 图所属会话: +// 当 ctx 运行于子代理分支内时取分支父级,否则 +// 取外层 Run 被驱动的 session id(两者都未设置时为空)。 // -// A tool that dispatches background work — the sub-agent spawn tool — passes this -// as the parent session, so a sub-agent spawned from inside another sub-agent -// stays attributed to the same top-level graph instead of to an id that owns no -// graph. +// 派发后台工作的工具 —— 子代理派生工具 —— 把它 +// 作为父会话传入,使在另一个子代理内部派生的子代理 +// 保持在同一个顶层图的归属之下,而不是归属到一个不拥有 +// 任何图的 id。 func FlowOwnerFromContext(ctx context.Context) string { if parent, isBranch := flowBranch(ctx); isBranch { return parent diff --git a/internal/runtime/loop/flow_test.go b/internal/runtime/loop/flow_test.go index 6c5b706..de10265 100644 --- a/internal/runtime/loop/flow_test.go +++ b/internal/runtime/loop/flow_test.go @@ -9,9 +9,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) -// TestRun_PublishesFlowGraph_Trunk wires a FlowPublisher with a capturing publish -// function and a live tracer, runs one tool round and asserts ws.graph_update was -// published with the input→llm→tool→output trunk (design/19 §1 P1). +// TestRun_PublishesFlowGraph_Trunk 给 FlowPublisher 接上捕获式 publish +// 函数与实时 tracer,跑一轮工具,并断言 ws.graph_update +// 以 input→llm→tool→output 主干发布(design/19 §1 P1)。 func TestRun_PublishesFlowGraph_Trunk(t *testing.T) { var mu sync.Mutex var seen []*trace.FlowGraph @@ -76,7 +76,7 @@ func TestRun_PublishesFlowGraph_Trunk(t *testing.T) { } } - // Walk the chain from the input node; it must reach output through llm/tool. + // 从 input 节点开始走链;它必须经 llm/tool 到达 output。 chain := []string{} cur := inputID for cur != "" { @@ -98,7 +98,7 @@ func TestRun_PublishesFlowGraph_Trunk(t *testing.T) { if chain[len(chain)-1] != "output" || !hasLLM || !hasTool { t.Fatalf("trunk chain broken: %v", chain) } - // The final frame should show the tool completed (done), not still running. + // 最终帧应显示该工具已完成(done),而不是仍在运行。 for _, n := range last.Nodes { if n.Kind == "tool" && n.Status != trace.FlowStatusDone { t.Fatalf("tool status = %q, want done", n.Status) @@ -106,8 +106,8 @@ func TestRun_PublishesFlowGraph_Trunk(t *testing.T) { } } -// nextNode returns the node that cur points to, or "" when cur has no outgoing -// edge (the terminal output node). A linear chain has at most one successor. +// nextNode 返回 cur 指向的节点;cur 没有出边时返回 ""(终端的 +// output 节点)。线性链至多有一个后继。 func nextNode(g *trace.FlowGraph, cur string) string { for _, e := range g.Edges { if e.From == cur { diff --git a/internal/runtime/loop/loop.go b/internal/runtime/loop/loop.go index 27063bf..1609678 100644 --- a/internal/runtime/loop/loop.go +++ b/internal/runtime/loop/loop.go @@ -1,14 +1,14 @@ -// Package loop implements the default agent execution engine of the Cyrene v2 -// runtime (DevDocs/design/04-运行时.md §1). It owns the loop that package agent -// deliberately leaves out: assemble context from memory, drive the model for up -// to MaxRounds steps, execute any requested tool calls under a per-invoke -// timeout, feed tool results back to the model, persist a checkpoint at every -// step, emit structured traces, deliver streamed deltas and run the -// memory-extraction hook. +// Package loop 实现 Cyrene v2 运行时默认的 agent 执行引擎 +// (DevDocs/design/04-运行时.md §1)。它拥有 package agent 刻意 +// 不做的那个循环:从记忆组装上下文、驱动模型至多 +// MaxRounds 个 step、在每次调用的超时下执行所有被请求的工具调用、 +// 把工具结果回灌给模型、每步持久化一个 checkpoint、 +// 发出结构化 trace、下发流式增量并运行 +// 记忆提取钩子。 // -// The loop is configured with injected dependencies rather than concrete wiring, -// so it stays leaf-testable with mocks (a mock adapter, mock guardrail and mock -// tool, plus real in-memory memory/persist stores and a buffered trace recorder). +// 循环以注入依赖而非具体接线来配置, +// 因此它保持可用 mock 做叶子测试(mock 适配器、mock 护栏与 mock +// 工具,加上真实的内存 memory/persist store 与带缓冲的 trace 记录器)。 package loop import ( @@ -28,62 +28,62 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) -// Constants that bound the loop. These are compile-time so no magic number -// appears in the run path. +// 界定循环的常量。它们是编译期常量,使运行路径中 +// 不出现魔法数字。 const ( - // DefaultMaxRounds is the tool-loop upper bound used when Options.MaxRounds - // is non-positive. It matches the agent package's own default. + // DefaultMaxRounds 是 Options.MaxRounds 非正时使用的 + // 工具循环上限。它与 agent 包自身的默认值一致。 DefaultMaxRounds = 8 - // StopReasonMaxRounds reports that the loop exhausted its round budget - // before reaching a final answer. + // StopReasonMaxRounds 报告循环在达到最终答案前 + // 用尽了轮次预算。 StopReasonMaxRounds = "max_rounds" - // StopReasonToolLoopTerminated reports that the critical-tool-loop safety net - // stopped the run after the same tool call was repeated identically across - // consecutive rounds. + // StopReasonToolLoopTerminated 报告关键工具循环安全网在 + // 同一个工具调用跨连续轮次被完全重复后 + // 停止了 run。 StopReasonToolLoopTerminated = "tool_loop_terminated" - // DefaultToolParallelism caps how many tool calls run concurrently when the - // loop runs a batch in parallel mode and no explicit cap is configured. + // DefaultToolParallelism 限制循环以并行模式运行一批时 + // 并发执行的工具调用数,未配置显式上限时生效。 DefaultToolParallelism = 4 ) -// Sentinel errors returned by the Loop. Use errors.Is to test for them; they are -// returned directly or wrapped with %w so the underlying cause stays available. +// Loop 返回的哨兵错误。用 errors.Is 检测它们;它们 +// 被直接返回或以 %w 包装,使底层原因始终可用。 var ( - // ErrNilAgent is returned by Run when neither a default agent (Options.Agent) - // nor a per-role agent pool (Options.Agents) was configured. + // ErrNilAgent 在既未配置默认 agent(Options.Agent) + // 也未配置按角色 agent 池(Options.Agents)时由 Run 返回。 ErrNilAgent = errors.New("loop: nil agent") - // ErrNoAdapter is returned by Run (streaming path) when the agent was built - // without a model Adapter. + // ErrNoAdapter 在 agent 构建时未带 model Adapter 时 + // 由 Run(流式路径)返回。 ErrNoAdapter = errors.New("loop: no model adapter") ) -// Result is the outcome of a completed Run. +// Result 是一次已完成 Run 的结果。 type Result struct { - // Text is the final assistant text, or empty when the loop exhausted its - // budget without a final answer. + // Text 是最终的 assistant 文本;循环在无最终答案的情况下 + // 耗尽预算时为空。 Text string - // Steps is the number of model steps executed. + // Steps 是执行的模型步数。 Steps int - // StopReason classifies how the loop ended: an agent StopReason* value or - // StopReasonMaxRounds. + // StopReason 归类循环如何结束:一个 agent StopReason* 值或 + // StopReasonMaxRounds。 StopReason string - // ToolCallsExecuted is the number of tool calls the loop invoked, including - // those that failed. + // ToolCallsExecuted 是循环调用的工具调用数,含 + // 失败的那些。 ToolCallsExecuted int - // Mood is the persona's mood label for this turn, as returned by the - // OnTurnEnd hook ("" when no hook is wired). The persistence layer carries it - // onto the assistant message (design/16 §3.3). + // Mood 是该 turn 的人格情绪标签,由 + // OnTurnEnd 钩子返回(未接线钩子时为 "")。持久化层把它 + // 带到 assistant 消息上(design/16 §3.3)。 Mood string } -// Loop is the default agent execution engine. It is configured once via New and -// safe for concurrent Run calls as long as the injected dependencies (agent, -// dispatcher, stores, recorder, tracer) are themselves concurrency-safe, which -// they are by contract. Run keeps all loop state on the stack. +// Loop 是默认的 agent 执行引擎。它经 New 一次性配置, +// 只要注入的依赖(agent、dispatcher、store、recorder、tracer)自身 +// 并发安全(按契约它们是),就安全支持并发 Run 调用。 +// Run 把所有循环状态保留在栈上。 type Loop struct { agent *agent.Agent agents map[string]*agent.Agent @@ -120,11 +120,11 @@ type Loop struct { log *slog.Logger } -// New builds a Loop from opts, applying defaults. A non-positive MaxRounds -// selects DefaultMaxRounds; a nil Log is replaced by a discard logger tagged with -// source "runtime/loop". Every dependency may be nil and is tolerated until the -// step that needs it (mirroring the run-time nil-tolerance of the underlying -// packages); Run returns ErrNilAgent when no agent is configured. +// New 由 opts 构建 Loop 并应用默认值。MaxRounds 非正时 +// 选用 DefaultMaxRounds;Log 为 nil 时替换为标记来源 +// "runtime/loop" 的丢弃 logger。每个依赖都可以为 nil,并容忍到 +// 需要它的那一步(与底层包的运行时 nil 容忍一致); +// 未配置 agent 时 Run 返回 ErrNilAgent。 func New(opts Options) *Loop { maxRounds := opts.MaxRounds if maxRounds <= 0 { @@ -180,15 +180,15 @@ func New(opts Options) *Loop { } } -// MaxRounds returns the tool-loop upper bound configured for the loop. +// MaxRounds 返回为该循环配置的工具循环上限。 func (l *Loop) MaxRounds() int { return l.maxRounds } -// Agent returns the injected default agent (Options.Agent). The per-role pool is -// reached through the turn's context, not this getter. +// Agent 返回注入的默认 agent(Options.Agent)。按角色池 +// 经该 turn 的 context 触达,而不是经这个 getter。 func (l *Loop) Agent() *agent.Agent { return l.agent } -// sourcedLog returns a logger tagged with the module source "runtime/loop", -// replacing a nil logger with a discard one so the loop can always log safely. +// sourcedLog 返回标记模块来源 "runtime/loop" 的 logger, +// 把 nil logger 替换为丢弃 logger,使循环总能安全地记录日志。 func sourcedLog(l *slog.Logger) *slog.Logger { if l == nil { l = slog.New(slog.NewTextHandler(io.Discard, nil)) diff --git a/internal/runtime/loop/loop_extra_test.go b/internal/runtime/loop/loop_extra_test.go index 44a6c7f..e52f196 100644 --- a/internal/runtime/loop/loop_extra_test.go +++ b/internal/runtime/loop/loop_extra_test.go @@ -12,8 +12,8 @@ import ( func TestRun_DuplicateCallInBatch_Skipped(t *testing.T) { t.Parallel() - // Two identical calls in one batch: the second must be skipped, not executed, - // and fed back as a duplicate hint. + // 一批中有两个相同调用:第二个必须被跳过而非执行, + // 并作为重复提示回灌。 call := `{"tool_calls":[{"name":"echo","args":{"q":"x"}},{"name":"echo","args":{"q":"x"}}]}` ad := &mockAdapter{chat: []mgResp{ {resp: model.Response{Content: call}}, @@ -37,8 +37,8 @@ func TestRun_DuplicateCallInBatch_Skipped(t *testing.T) { t.Fatalf("StopReason = %q, want stop", res.StopReason) } last := ad.reqs[len(ad.reqs)-1].Messages - // Round 1 appended two tool messages: the executed call result, then the - // duplicate skip. The last two messages before the final turn are those. + // 第 1 轮追加了两条工具消息:已执行调用的结果,然后是 + // 重复跳过。最终轮之前的最后两条消息就是它们。 result := last[len(last)-2] skip := last[len(last)-1] if !strings.Contains(result.Content[0].Text, "hi") { @@ -51,8 +51,8 @@ func TestRun_DuplicateCallInBatch_Skipped(t *testing.T) { func TestRun_CriticalToolLoop_Terminates(t *testing.T) { t.Parallel() - // The identical call repeats across rounds: round 2 is blocked (critical - // loop flagged), round 3 terminates the run. + // 相同调用跨轮重复:第 2 轮被拦截(标记为关键 + // 循环),第 3 轮终止该 run。 call := `{"tool_calls":[{"name":"echo","args":{"q":"x"}}]}` ad := &mockAdapter{chat: []mgResp{ {resp: model.Response{Content: call}}, @@ -151,8 +151,8 @@ func TestRun_Sequential_ExecutesAll(t *testing.T) { func TestRun_ArgumentValidation_ErrorKind(t *testing.T) { t.Parallel() - // Missing required field -> the fed-back failure carries errorKind - // argument-validation so the model can re-issue a corrected call. + // 缺少必填字段 -> 回灌的失败携带 errorKind + // argument-validation,使模型能重新发出修正后的调用。 call := `{"tool_calls":[{"name":"req","args":{}}]}` ad := &mockAdapter{chat: []mgResp{ {resp: model.Response{Content: call}}, @@ -268,8 +268,8 @@ func TestRun_AfterCall_RewritesResult(t *testing.T) { func TestRun_ToolCallRepair_FencedJSON(t *testing.T) { t.Parallel() - // A code-fenced tool-call envelope is repaired and executed rather than - // silently becoming a text answer. + // 围栏代码块中的工具调用信封被修复并执行,而不是 + // 悄悄变成文本答案。 call := "```json\n{\"tool_calls\":[{\"name\":\"echo\",\"args\":{\"q\":\"hi\"}}]}\n```" ad := &mockAdapter{chat: []mgResp{ {resp: model.Response{Content: call}}, diff --git a/internal/runtime/loop/loop_test.go b/internal/runtime/loop/loop_test.go index 6ce617e..808d659 100644 --- a/internal/runtime/loop/loop_test.go +++ b/internal/runtime/loop/loop_test.go @@ -84,8 +84,8 @@ func TestRun_ToolCallThenFinal_FeedsBack(t *testing.T) { func TestRun_MaxRounds(t *testing.T) { t.Parallel() - // Distinct calls per round so the critical-tool-loop safety net does not - // intervene; the goal here is to exercise the round budget. + // 每轮使用不同的调用,使关键工具循环安全网不会 + // 介入;这里的目标是演练轮次预算。 call1 := `{"tool_calls":[{"name":"echo","args":{"q":"x"}}]}` call2 := `{"tool_calls":[{"name":"echo","args":{"q":"y"}}]}` ad := &mockAdapter{chat: []mgResp{ @@ -233,8 +233,8 @@ func TestRun_MemoryInject(t *testing.T) { if _, err := l.Run(context.Background(), "sess", nil); err != nil { t.Fatalf("Run: %v", err) } - // An empty conversation still routes through injectMemory; the retriever - // returns the persona's items by recency when the query is blank. + // 空对话仍会走 injectMemory;查询为空时 retriever + // 按最近程度返回该角色的条目。 if len(ad.reqs) == 0 { t.Fatal("no request captured") } @@ -354,8 +354,8 @@ func TestNew_DefaultMaxRounds(t *testing.T) { } } -// decodeSpanLines reads every JSON span emitted as JSONL into recs, failing the -// test if any line is malformed. +// decodeSpanLines 把作为 JSONL 发出的每个 JSON span 读入 recs, +// 任何一行格式错误都让测试失败。 func decodeSpanLines(t *testing.T, data []byte) []trace.SpanRecord { t.Helper() sc := bufio.NewScanner(bytes.NewReader(data)) diff --git a/internal/runtime/loop/memory.go b/internal/runtime/loop/memory.go index 811bda3..8bf564d 100644 --- a/internal/runtime/loop/memory.go +++ b/internal/runtime/loop/memory.go @@ -9,32 +9,32 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// memoryExtractTimeout bounds one background extraction. It only governs the -// detached extraction context, so a hung model call cannot leak a goroutine for -// the process lifetime. +// memoryExtractTimeout 限制单次后台提取。它只管辖 +// 脱离出来的提取 context,因此挂起的模型调用不会让一个 goroutine +// 泄露到进程生命周期结束。 const memoryExtractTimeout = 60 * time.Second -// asyncExtractor is the extractor capability the loop detaches to a background -// goroutine: memory.LLMExtractor implements it through ExtractAsync (design/03 §1 -// LLM 异步自动提取), returning a channel that delivers exactly one result. -// Deterministic extractors (memory.ManualExtractor) do not implement it and stay -// on the synchronous path. +// asyncExtractor 是循环脱离到后台 goroutine 的提取器能力: +// memory.LLMExtractor 经 ExtractAsync 实现它(design/03 §1 +// LLM 异步自动提取),返回一个恰好投递一个结果的 channel。 +// 确定性提取器(memory.ManualExtractor)不实现它,留在 +// 同步路径上。 type asyncExtractor interface { ExtractAsync(ctx context.Context, conversation []model.Message) (<-chan memory.AsyncResult, error) } -// injectMemory retrieves the persona's relevant long-term memories and prepends -// them as a leading system message, so the model can ground its answer in recalled -// context (design §6 / §2). It is re-run every round via assembleContext so the -// retrieval reflects the latest conversation rather than a first-turn snapshot. It -// returns the input messages unchanged when no retriever/persona is configured, -// retrieval fails, or nothing relevant is found. +// injectMemory 检索该角色的相关长期记忆并把它们作为前导 +// 系统消息前置,使模型能把回答建立在召回出的上下文之上 +// (design §6 / §2)。它经 assembleContext 每轮重新运行,因此 +// 检索反映最新对话而非首轮快照。未配置 +// retriever/persona、检索失败、或没找到相关内容时, +// 它原样返回输入消息。 // -// The session env on ctx gates the retrieval: the owner-full env keeps the full -// recall (unchanged behavior); the restricted env (group chat / stranger, -// design/02 §3, design/11 §2) is read-only isolation — only non-owner-private -// project memory is injected so no owner-sensitive memory leaks to a group or -// stranger. +// ctx 上的会话环境为检索设门:owner 全权限环境保留完整 +// 召回(行为不变);受限环境(群聊 / 陌生人, +// design/02 §3, design/11 §2)是只读隔离 —— 只注入 +// 非主人私有的项目记忆,使主人敏感记忆不泄露给群聊或 +// 陌生人。 func (l *Loop) injectMemory(ctx context.Context, messages []model.Message) []model.Message { block := l.memoryBlock(ctx, messages) if block == "" { @@ -46,11 +46,11 @@ func (l *Loop) injectMemory(ctx context.Context, messages []model.Message) []mod return out } -// memoryBlock renders the round's memory block, or "" when there is nothing to -// inject (no retriever/persona, a retrieval error, or no relevant memory). It is -// split out from injectMemory because the assembly places other leading blocks -// between the memory block and the conversation (the role context of design/22 -// §7-5), so it needs the block itself rather than a pre-prefixed slice. +// memoryBlock 渲染该轮的记忆块;无可注入内容时返回 ""(无 +// retriever/persona、检索出错、或没有相关记忆)。它 +// 从 injectMemory 中拆出,因为组装会在记忆块与对话之间 +// 放置其他前导块(design/22 §7-5 的角色上下文), +// 因此它需要块本身而不是已前置好的切片。 func (l *Loop) memoryBlock(ctx context.Context, messages []model.Message) string { personaID := l.personaFor(ctx) if l.retriever == nil || personaID == "" { @@ -65,11 +65,11 @@ func (l *Loop) memoryBlock(ctx context.Context, messages []model.Message) string return block } -// retrieveMemoryBlock returns the memory injection block for env and the turn's -// role. The owner-full env keeps the pre-existing Inject behavior. The restricted -// env (group chat / stranger) injects only project-scope memory (人人可读, not -// owner-private) and marks the block as group-isolated so callers can tell an -// isolated read from the owner's full read. +// retrieveMemoryBlock 返回 env 与该 turn 角色的记忆注入块。 +// owner 全权限环境保持既有的 Inject 行为。受限 +// 环境(群聊 / 陌生人)只注入项目作用域记忆(人人可读, +// 非主人私有),并把该块标记为群聊隔离,使调用方能区分 +// 隔离读取与主人的完整读取。 func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, personaID, query string) (string, error) { if !env.Restricted() { return l.retriever.Inject(ctx, personaID, query, 0) @@ -90,9 +90,9 @@ func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, personaI return formatMemoryBlock(shared, "群聊隔离"), nil } -// formatMemoryBlock builds the leading system block from retrieved results. The -// non-empty marker prefixes the block so an isolated group read is distinguishable -// from the owner's full read. +// formatMemoryBlock 由检索结果构建前导系统块。非空 +// marker 作为该块的前缀,使隔离的群聊读取可与 +// 主人的完整读取区分开。 func formatMemoryBlock(results []memory.Result, marker string) string { var b strings.Builder if marker != "" { @@ -110,28 +110,28 @@ func formatMemoryBlock(results []memory.Result, marker string) string { return b.String() } -// assembleContext builds the model-visible message list for one round (design -// §1.3 每轮 assemble, the loop's equivalent of OpenClaw's transformContext): it -// re-injects the persona's relevant long-term memory — retrieval is re-run every -// round from the current conversation, not cached from the first turn, so it -// supports cross-round refresh — folds in the per-session working-memory summary -// when present, renders the role/speaker attribution of the conversation (design/22 -// §7-5), and applies the optional TransformContext hook last so a caller can -// project/filter what the model actually sees. It returns the input unchanged -// when nothing is configured. +// assembleContext 为一轮构建模型可见的消息列表(design +// §1.3 每轮 assemble,循环中与 OpenClaw 的 transformContext 等价之物):它 +// 重新注入该角色的相关长期记忆 —— 检索每轮都从 +// 当前对话重新运行,不从首轮缓存,因此 +// 支持跨轮刷新 —— 在存在时并入每会话的工作记忆摘要, +// 渲染对话的角色/发言人归属(design/22 +// §7-5),最后应用可选的 TransformContext 钩子,使调用方能 +// 投影/过滤模型实际所见。什么都没配置时 +// 它原样返回输入。 // -// The existing order is preserved exactly: the leading blocks are the per-round -// time, the working-memory summary and the memory block, in that order; the role -// declaration sits right after them (immediately before the conversation it -// describes) and the speaker boundary notes are inserted into the conversation -// itself. A conversation with no speaker label renders no attribution at all, so -// every single-role path keeps the prompt it had before multi-character support. +// 既有顺序被精确保留:前导块依次是每轮的 +// 时间、工作记忆摘要与记忆块;角色 +// 声明紧接其后(就在它所描述的对话之前),而发言人 +// 边界说明被插入对话本身。没有发言人标签的对话 +// 完全不渲染归属,因此每条单角色路径都保持多角色支持 +// 之前的提示词。 func (l *Loop) assembleContext(ctx context.Context, messages []model.Message, round int) []model.Message { var leading []model.Message - // Per-round "current time" so the model naturally knows the wall clock (not a - // first-turn snapshot). Keyed on a configured timezone so tests and no-TZ runs - // keep their exact leading-message counts. It sits before working memory / the - // memory block and can still be projected by transformContext. + // 每轮「当前时间」,使模型自然知道墙上时钟(而非首轮 + // 快照)。以已配置的时区为条件,使测试与无时区的运行 + // 保持其精确的前导消息数。它位于工作记忆 / + // 记忆块之前,且仍可被 transformContext 投影。 if l.timeLocation != nil { leading = append(leading, l.currentTimeBlock()) } @@ -154,11 +154,11 @@ func (l *Loop) assembleContext(ctx context.Context, messages []model.Message, ro return out } -// compactIfNeeded compacts the conversation when its estimated context exceeds -// the configured MaxContextTokens. It is a no-op when compaction is disabled -// (non-positive MaxContextTokens), the compactor is nil, the estimate is within -// budget, or the compactor returns an error — a compaction failure is logged and -// the conversation is kept as-is rather than failing the run. +// compactIfNeeded 在估算上下文超出配置的 MaxContextTokens +// 时压缩对话。压缩被关闭(MaxContextTokens 非正)、 +// compactor 为 nil、估算在预算内、或 compactor 返回错误时 +// 它是空操作 —— 压缩失败会被记录日志并保持对话 +// 原样,而不是让 run 失败。 func (l *Loop) compactIfNeeded(ctx context.Context, messages []model.Message) []model.Message { if l.maxContextTokens <= 0 || l.compactor == nil { return messages @@ -174,14 +174,14 @@ func (l *Loop) compactIfNeeded(ctx context.Context, messages []model.Message) [] return compacted } -// extractMemory runs the extraction hook (design §6) over the current -// conversation and persists any derived items. Errors are logged, never fatal, so -// a bad extraction cannot abort the run. +// extractMemory 在当前对话上运行提取钩子(design §6) +// 并持久化导出的条目。错误会被记录日志,绝不致命,因此 +// 一次糟糕的提取无法中止 run。 // -// In a restricted context (group chat / stranger, !MemWritable) extraction is -// skipped entirely: the conversation may read shared memory read-only and may -// never persist anything that could leak owner-sensitive information (design/02 -// §3, design/11 §2). +// 在受限上下文(群聊 / 陌生人,!MemWritable)中提取被 +// 完全跳过:该对话可以只读地读取共享记忆,但绝不 +// 持久化任何可能泄露主人敏感信息的东西(design/02 +// §3, design/11 §2)。 func (l *Loop) extractMemory(ctx context.Context, messages []model.Message, step int) { env := sessionEnvFrom(ctx) if env.Restricted() { @@ -199,18 +199,18 @@ func (l *Loop) extractMemory(ctx context.Context, messages []model.Message, step l.persistMemories(ctx, items, step) } -// extractMemoryAsync triggers the extraction hook at the turn boundary without -// blocking the reply (design/03 §1: 每轮对话后后台异步抽取). When the configured -// extractor can extract asynchronously (the LLM extractor) the conversation is -// handed to a background goroutine and this call returns immediately, so a slow -// extraction model never delays the response. A deterministic extractor -// (ManualExtractor) does not implement asyncExtractor and is run inline, which -// keeps its cheap "记住 X" path observable and unchanged. +// extractMemoryAsync 在 turn 边界触发提取钩子而不阻塞 +// 回复(design/03 §1: 每轮对话后后台异步抽取)。配置的 +// 提取器能异步提取(LLM extractor)时,对话被交给 +// 后台 goroutine,本调用立即返回,因此慢速 +// 提取模型绝不延迟响应。确定性提取器 +// (ManualExtractor)不实现 asyncExtractor,就地运行,这 +// 使其廉价的 "记住 X" 路径保持可观测且不变。 // -// The same gates as extractMemory apply: a restricted conversation is never -// extracted (read-only isolation, design/02 §3 / design/11 §2), and a missing -// extractor or store is a no-op. Extraction failures and store failures are -// logged with the loop's source and never propagated. +// 与 extractMemory 相同的门仍然适用:受限对话绝不 +// 被提取(只读隔离,design/02 §3 / design/11 §2),缺少 +// extractor 或 store 时为空操作。提取失败与 store 失败会 +// 带循环的来源记录日志,绝不向上传播。 func (l *Loop) extractMemoryAsync(ctx context.Context, messages []model.Message, step int) { env := sessionEnvFrom(ctx) if env.Restricted() { @@ -225,12 +225,12 @@ func (l *Loop) extractMemoryAsync(ctx context.Context, messages []model.Message, l.extractMemory(ctx, messages, step) return } - // The turn's context dies with the request while the extraction deliberately - // outlives it, so the goroutine gets a detached copy of the context (values — - // the session env among them — are kept) under its own timeout. + // 该 turn 的 context 随请求消亡,而提取刻意比它活得 + // 更久,因此 goroutine 拿到一份脱离的 context 副本(值 —— + // 其中包括会话环境 —— 被保留),并处于自己的超时之下。 dctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), memoryExtractTimeout) - // The caller keeps appending to messages as the turn continues, so the - // goroutine reads a private snapshot instead of racing on the shared slice. + // 随着该 turn 继续,调用方会不断向 messages 追加,因此 + // goroutine 读取一份私有快照,而不是在共享切片上竞争。 snapshot := append([]model.Message(nil), messages...) results, err := async.ExtractAsync(dctx, snapshot) if err != nil { @@ -252,13 +252,13 @@ func (l *Loop) extractMemoryAsync(ctx context.Context, messages []model.Message, }() } -// persistMemories writes extracted items to the memory store, stamping every item -// with the role that produced the turn. The assignment is unconditional on -// purpose: the extractor is deliberately role-agnostic (it no longer holds a -// persona baked in at construction, cmd/cyrene/extractor.go), so a multi-role -// process would otherwise file every memory under one role. A blank role (no -// persona configured at all) leaves the item as the extractor produced it. Store -// failures are logged and never fatal, so one rejected item cannot abort a turn. +// persistMemories 把提取出的条目写入记忆 store,为每个条目 +// 打上产出该 turn 的角色。该赋值刻意无条件: +// extractor 刻意不感知角色(它不再持有一个在构造期 +// 内嵌的人格,cmd/cyrene/extractor.go),否则多角色 +// 进程会把每条记忆都归到一个角色下。角色为空(完全未配置 +// persona)时条目保持 extractor 产出时的样子。store +// 失败会被记录日志,绝不致命,因此一条被拒的条目无法中止一个 turn。 func (l *Loop) persistMemories(ctx context.Context, items []memory.MemoryItem, step int) { personaID := l.personaFor(ctx) for _, it := range items { @@ -271,8 +271,8 @@ func (l *Loop) persistMemories(ctx context.Context, items []memory.MemoryItem, s } } -// queryFromMessages derives a retrieval query from the conversation: the last -// user turn's text, falling back to the last message's text, then "". +// queryFromMessages 从对话导出检索查询:最后一个用户 +// turn 的文本,回退到最后一条消息的文本,再回退到 ""。 func queryFromMessages(messages []model.Message) string { for i := len(messages) - 1; i >= 0; i-- { if messages[i].Role == model.RoleUser { @@ -285,8 +285,8 @@ func queryFromMessages(messages []model.Message) string { return "" } -// messageText returns the concatenated plain-text payload of a message's text -// parts, which is the shared view used to build a retrieval query. +// messageText 返回一条消息各文本部件的拼接纯文本载荷, +// 这是用于构建检索查询的共享视图。 func messageText(m model.Message) string { var b strings.Builder for _, part := range m.Content { diff --git a/internal/runtime/loop/memory_async_test.go b/internal/runtime/loop/memory_async_test.go index 42d3d65..c854dd2 100644 --- a/internal/runtime/loop/memory_async_test.go +++ b/internal/runtime/loop/memory_async_test.go @@ -10,19 +10,19 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// The LLM extractor is the async-capable implementation behind design/03 §1: the -// loop type-asserts this capability to detach extraction to a background -// goroutine, so the contract is locked here rather than discovered at runtime. +// LLM extractor 是 design/03 §1 背后具备异步能力的实现:循环 +// 对此能力做类型断言,把提取脱离到后台 +// goroutine,因此该契约在此锁定,而不是在运行时才发现。 var ( _ memory.Extractor = (*memory.LLMExtractor)(nil) _ asyncExtractor = (*memory.LLMExtractor)(nil) _ memory.Extractor = (*memory.ManualExtractor)(nil) ) -// gatedExtractor is a memory.Extractor that also implements asyncExtractor. Its -// extraction blocks until release is closed (or ctx is canceled), so a test can -// prove the loop's turn finishes while extraction is still in flight, and that -// the extraction survives the caller's own cancellation. +// gatedExtractor 是一个同时实现 asyncExtractor 的 memory.Extractor。 +// 它的提取会阻塞直到 release 被关闭(或 ctx 被取消),使测试能 +// 证明循环的 turn 在提取仍在途时就结束,且 +// 提取能在调用方自身的取消下存活。 type gatedExtractor struct { release chan struct{} started chan struct{} @@ -68,10 +68,10 @@ func (g *gatedExtractor) ExtractAsync(ctx context.Context, conv []model.Message) return out, nil } -// TestExtractMemoryAsync_LLMExtractorThroughLoop wires the real LLM extractor -// (not a fake) into the loop: after the turn settles, the extractor's own model -// call runs in the background and its parsed items land in the store, so the -// production path (design/03 §1 LLM 异步自动提取) is exercised end to end. +// TestExtractMemoryAsync_LLMExtractorThroughLoop 把真实的 LLM extractor +// (非假实现)接入循环:turn 落定后,extractor 自身的模型 +// 调用在后台运行,其解析出的条目落入 store,因此 +// 生产路径(design/03 §1 LLM 异步自动提取)被端到端演练。 func TestExtractMemoryAsync_LLMExtractorThroughLoop(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) @@ -112,8 +112,8 @@ func TestExtractMemoryAsync_LLMExtractorThroughLoop(t *testing.T) { } } -// waitForMemoryCount polls the persona's stored memories until want is reached, -// failing the test when the extraction never lands. +// waitForMemoryCount 轮询该角色已存的记忆直到达到 want, +// 提取始终未落定时让测试失败。 func waitForMemoryCount(t *testing.T, store *memory.Store, personaID string, want int) { t.Helper() deadline := time.Now().Add(2 * time.Second) @@ -132,10 +132,10 @@ func waitForMemoryCount(t *testing.T, store *memory.Store, personaID string, wan } } -// TestExtractMemoryAsync_DoesNotBlockTurn proves the turn returns while the LLM -// extraction is still running, and that the extraction completes (and persists) -// after the caller's context has already been canceled — the detached context -// keeps a request-scoped cancellation from aborting the background extraction. +// TestExtractMemoryAsync_DoesNotBlockTurn 证明 turn 在 LLM +// 提取仍在运行时返回,且提取在调用方的 context 已被取消之后 +// 仍能完成(并持久化)—— 脱离的 context +// 使请求作用域的取消无法中止后台提取。 func TestExtractMemoryAsync_DoesNotBlockTurn(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) @@ -171,8 +171,8 @@ func TestExtractMemoryAsync_DoesNotBlockTurn(t *testing.T) { t.Fatalf("store already holds %d items after Run, want 0 (extraction still in flight)", len(items)) } - // The turn is over, so its context is canceled; the background extraction - // must still finish and persist. + // 该 turn 已结束,因此其 context 被取消;后台提取 + // 仍必须完成并持久化。 cancel() select { case <-ext.started: @@ -183,9 +183,9 @@ func TestExtractMemoryAsync_DoesNotBlockTurn(t *testing.T) { waitForMemoryCount(t, store, "persona-a", 1) } -// TestExtractMemoryAsync_RestrictedEnvNeverDispatches proves the group-chat / -// stranger isolation still gates the async path: nothing is dispatched, so no -// owner-private memory can be written from a read-only conversation. +// TestExtractMemoryAsync_RestrictedEnvNeverDispatches 证明群聊 / +// 陌生人隔离对异步路径同样设门:什么也不派发,因此从只读 +// 对话绝无法写入主人私有记忆。 func TestExtractMemoryAsync_RestrictedEnvNeverDispatches(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) @@ -207,9 +207,9 @@ func TestExtractMemoryAsync_RestrictedEnvNeverDispatches(t *testing.T) { } } -// TestExtractMemoryAsync_ManualExtractorStaysSync proves the deterministic path -// is unchanged: an extractor without the async capability is still run inline, so -// the explicit "记住 X" memory is stored by the time the call returns. +// TestExtractMemoryAsync_ManualExtractorStaysSync 证明确定性路径 +// 未变:不具备异步能力的 extractor 仍就地运行,因此 +// 显式的 "记住 X" 记忆在该调用返回时已存入。 func TestExtractMemoryAsync_ManualExtractorStaysSync(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) diff --git a/internal/runtime/loop/memory_gating_test.go b/internal/runtime/loop/memory_gating_test.go index 498f146..8bd24aa 100644 --- a/internal/runtime/loop/memory_gating_test.go +++ b/internal/runtime/loop/memory_gating_test.go @@ -10,8 +10,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// seedMemories fills the store with one memory per scope so a test can assert the -// scope filtering of the memory gate. +// seedMemories 为每个作用域各填一条记忆到 store,使测试能断言 +// 记忆门的作用域过滤。 func seedMemories(t *testing.T, store *memory.Store) { t.Helper() ctx := context.Background() @@ -27,8 +27,8 @@ func seedMemories(t *testing.T, store *memory.Store) { } } -// TestInjectMemory_OwnerEnv_InjectsAllScopes proves the owner-full env keeps the -// pre-existing full recall: user, persona and project memories are all injected. +// TestInjectMemory_OwnerEnv_InjectsAllScopes 证明 owner 全权限环境保持 +// 既有的完整召回:用户、角色与项目记忆都被注入。 func TestInjectMemory_OwnerEnv_InjectsAllScopes(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) @@ -49,9 +49,9 @@ func TestInjectMemory_OwnerEnv_InjectsAllScopes(t *testing.T) { } } -// TestInjectMemory_RestrictedEnv_OnlyProject proves the group-chat/stranger env is -// read-only isolation: only the non-owner-private project memory is injected, the -// owner's user/persona memory is excluded, and the block is marked 群聊隔离. +// TestInjectMemory_RestrictedEnv_OnlyProject 证明群聊/陌生人环境是 +// 只读隔离:只注入非主人私有的项目记忆, +// 主人的用户/角色记忆被排除,且该块被标记为 群聊隔离。 func TestInjectMemory_RestrictedEnv_OnlyProject(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) @@ -75,13 +75,13 @@ func TestInjectMemory_RestrictedEnv_OnlyProject(t *testing.T) { } } -// TestInjectMemory_Restricted_NoProjectMemory_NoInject proves that when a group -// or stranger context has no non-owner-private (project) memory, nothing is -// injected — the conversation is not seeded with owner context. +// TestInjectMemory_Restricted_NoProjectMemory_NoInject 证明当群聊 +// 或陌生人上下文没有非主人私有(项目)记忆时,什么也不 +// 注入 —— 该对话不会用主人的上下文来播种。 func TestInjectMemory_Restricted_NoProjectMemory_NoInject(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) - // Only owner-private (user/persona) memory exists. + // 只存在主人私有(用户/角色)记忆。 for _, it := range []memory.MemoryItem{ {PersonaID: "persona-a", Content: "用户习惯:喜欢冰美式", Scope: memory.ScopeUser, Kind: memory.KindRelationship}, } { @@ -99,8 +99,8 @@ func TestInjectMemory_Restricted_NoProjectMemory_NoInject(t *testing.T) { } } -// TestExtractMemory_OwnerEnv_Writes proves the owner-full env persists extracted -// memory as before. +// TestExtractMemory_OwnerEnv_Writes 证明 owner 全权限环境像从前 +// 一样持久化提取出的记忆。 func TestExtractMemory_OwnerEnv_Writes(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) @@ -118,8 +118,8 @@ func TestExtractMemory_OwnerEnv_Writes(t *testing.T) { } } -// TestExtractMemory_RestrictedEnv_SkipsWrite proves the group-chat/stranger env -// never writes shared memory: extraction is skipped, the store stays empty. +// TestExtractMemory_RestrictedEnv_SkipsWrite 证明群聊/陌生人环境 +// 绝不写共享记忆:提取被跳过,store 保持为空。 func TestExtractMemory_RestrictedEnv_SkipsWrite(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) @@ -137,10 +137,10 @@ func TestExtractMemory_RestrictedEnv_SkipsWrite(t *testing.T) { } } -// TestSessionEnvFrom_DefaultOwnerFull locks the backward-compatible default: a -// context with no attached env resolves to the owner-full environment, so callers -// that do not set an env (Web main session, internal automated tasks) keep full -// memory read+write. +// TestSessionEnvFrom_DefaultOwnerFull 钉住向后兼容的默认值:未挂载 +// env 的 context 解析为 owner 全权限环境,因此未设置 env 的 +// 调用方(Web 主会话、内部自动化任务)保持完整的 +// 记忆读写。 func TestSessionEnvFrom_DefaultOwnerFull(t *testing.T) { t.Parallel() env := sessionEnvFrom(context.Background()) diff --git a/internal/runtime/loop/mocks_test.go b/internal/runtime/loop/mocks_test.go index 36bb661..eb128ae 100644 --- a/internal/runtime/loop/mocks_test.go +++ b/internal/runtime/loop/mocks_test.go @@ -18,17 +18,17 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) -// mgResp is one scripted adapter result: a response plus an optional error. +// mgResp 是一条脚本化的适配器结果:一个响应加可选错误。 type mgResp struct { resp model.Response err error } -// mockAdapter is a configurable model.Adapter for loop tests. It keeps two -// separate scripts and call counters (one for Chat, one for ChatStream) so a -// single instance serves both the non-streaming and streaming paths. Every -// request it is handed is recorded so tests can assert on the messages that were -// fed to the model (e.g. the injected memory or a fed-back tool result). +// mockAdapter 是供循环测试使用的可配置 model.Adapter。它维护两份 +// 独立的脚本与调用计数(一份给 Chat,一份给 ChatStream),使 +// 单个实例同时服务于非流式与流式路径。它收到的每个 +// 请求都被记录,使测试能断言喂给模型的 +// 消息(如注入的记忆或回灌的工具结果)。 type mockAdapter struct { name string @@ -76,7 +76,7 @@ func (m *mockAdapter) ChatStream(_ context.Context, req model.Request, on func(m return m.stream[i].resp, m.stream[i].err } -// mockGuard is a configurable guardrail.Guardrail for loop tests. +// mockGuard 是供循环测试使用的可配置 guardrail.Guardrail。 type mockGuard struct { inputVerdict *guardrail.Verdict outputVerdict *guardrail.Verdict @@ -92,9 +92,9 @@ func (m *mockGuard) CheckOutput(_ context.Context, _ *model.Response) (*guardrai return m.outputVerdict, m.outputErr } -// mockTool is a minimal sdk.Tool for loop tests. It supports a delay so a test -// can exercise the per-invoke timeout, and an error so a test can verify that a -// failed call is fed back to the model. +// mockTool 是供循环测试使用的最小 sdk.Tool。它支持延迟,使测试 +// 能演练每次调用的超时;也支持错误,使测试能验证 +// 失败的调用被回灌给模型。 type mockTool struct { name string desc string @@ -126,12 +126,12 @@ func (m *mockTool) Execute(ctx context.Context, _ any) (any, error) { return m.result, m.err } -// testLogger returns a logger that writes nowhere, keeping test output clean. +// testLogger 返回一个不写任何地方的 logger,保持测试输出干净。 func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } -// newTestAgent builds an Agent over ad with the given tools and no guardrails. +// newTestAgent 用给定工具、无护栏地在 ad 之上构建一个 Agent。 func newTestAgent(t *testing.T, ad model.Adapter, tools *tool.Dispatcher) *agent.Agent { t.Helper() a, err := agent.New("You are a helpful assistant.", tools, nil, agent.WithModel(ad, nil)) @@ -141,14 +141,14 @@ func newTestAgent(t *testing.T, ad model.Adapter, tools *tool.Dispatcher) *agent return a } -// dispatcherWith builds a Dispatcher with the default tool timeout. +// dispatcherWith 用默认工具超时构建一个 Dispatcher。 func dispatcherWith(t *testing.T, tools ...sdk.Tool) *tool.Dispatcher { t.Helper() return dispatcherWithTimeout(t, 0, tools...) } -// dispatcherWithTimeout builds a Dispatcher applying a custom default timeout -// (a non-positive value selects the package default) and registering tools. +// dispatcherWithTimeout 构建一个应用自定义默认超时的 Dispatcher +// (非正值选用包默认值)并注册工具。 func dispatcherWithTimeout(t *testing.T, timeout time.Duration, tools ...sdk.Tool) *tool.Dispatcher { t.Helper() reg := registry.New() @@ -168,9 +168,9 @@ func dispatcherWithTimeout(t *testing.T, timeout time.Duration, tools ...sdk.Too return d } -// newTestMemoryStore opens an isolated in-memory SQLite database and returns a -// memory.Store over it. Each call owns a private DB, so parallel tests are -// isolated by construction. +// newTestMemoryStore 打开一个隔离的内存 SQLite 数据库并返回 +// 基于它的 memory.Store。每次调用拥有私有 DB,因此并行测试 +// 天然隔离。 func newTestMemoryStore(t *testing.T) *memory.Store { t.Helper() ctx := context.Background() @@ -186,8 +186,8 @@ func newTestMemoryStore(t *testing.T) *memory.Store { return s } -// newTestCheckpointStore opens an isolated in-memory SQLite database and returns -// a persist.Store over it. +// newTestCheckpointStore 打开一个隔离的内存 SQLite 数据库并返回 +// 基于它的 persist.Store。 func newTestCheckpointStore(t *testing.T) *persist.Store { t.Helper() ctx := context.Background() diff --git a/internal/runtime/loop/mute_ctx.go b/internal/runtime/loop/mute_ctx.go index 52a8902..6a5a359 100644 --- a/internal/runtime/loop/mute_ctx.go +++ b/internal/runtime/loop/mute_ctx.go @@ -2,23 +2,23 @@ package loop import "context" -// muteKey is the context key for the "suppress UI broadcast" flag. When set, -// a Run performs its work as usual but does NOT push stream/thinking/capacity -// events to the UI channel (OnStream / OnCapacity). This is used by internal -// autonomous passes (e.g. background reflection) whose intermediate reasoning -// must not leak into the user-facing chat stream. The loop itself is unchanged; -// only the outward event emission is muted. +// muteKey 是「抑制 UI 广播」标志的 context key。置位时, +// Run 照常完成工作,但不把 stream/thinking/capacity 事件 +// 推给 UI 通道(OnStream / OnCapacity)。内部自主流程 +// (如后台反思)使用它,这些流程的中间推理 +// 不得泄露进面向用户的聊天流。循环本身不变, +// 只是对外的广播被静音。 type muteKey struct{} -// WithMutedBroadcast returns a context whose loop Run suppresses UI stream and -// capacity broadcast, while still returning the final result to the caller. +// WithMutedBroadcast 返回一个 context,其 loop Run 抑制 UI 流式 +// 与 capacity 广播,但仍把最终结果返回给调用方。 func WithMutedBroadcast(ctx context.Context) context.Context { return context.WithValue(ctx, muteKey{}, true) } -// isMutedBroadcast reports whether the Run driving ctx should suppress UI -// broadcast. An unset flag (the common case) returns false so normal chat -// streaming is untouched. +// isMutedBroadcast 报告驱动 ctx 的 Run 是否应抑制 UI +// 广播。标志未置位(常见情况)返回 false,因此正常聊天 +// 流式不受影响。 func isMutedBroadcast(ctx context.Context) bool { v, _ := ctx.Value(muteKey{}).(bool) return v diff --git a/internal/runtime/loop/native_toolcall_http_test.go b/internal/runtime/loop/native_toolcall_http_test.go index c2ec637..3393a88 100644 --- a/internal/runtime/loop/native_toolcall_http_test.go +++ b/internal/runtime/loop/native_toolcall_http_test.go @@ -14,9 +14,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// anthropicToolRoundSSE is a native Anthropic stream that thinks, then emits a -// tool_use block with an empty text part — the exact shape that starved the old -// loop (which only looked for a JSON envelope in Content). +// anthropicToolRoundSSE 是一个原生 Anthropic 流:它先思考,然后发出 +// 一个带空文本部件的 tool_use 块 —— 正是拖垮旧循环(它只在 +// Content 中寻找 JSON 信封)的那种形态。 const anthropicToolRoundSSE = `data: {"type":"message_start","message":{"model":"m","usage":{"input_tokens":10}}} data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}} @@ -37,8 +37,8 @@ data: {"type":"message_stop"} ` -// anthropicTextRoundSSE is the follow-up stream: a plain text answer after the -// tool result was fed back. +// anthropicTextRoundSSE 是后续流:工具结果回灌后的一段 +// 纯文本答案。 const anthropicTextRoundSSE = `data: {"type":"message_start","message":{"model":"m","usage":{"input_tokens":30}}} data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} @@ -53,11 +53,11 @@ data: {"type":"message_stop"} ` -// TestRun_NativeToolCall_AnthropicAdapter_HTTP exercises the real loop path end -// to end through the real AnthropicChatAdapter against a stub /v1/messages -// server: round 1 streams a thinking + tool_use block with no text, the loop -// must execute it and re-ask, and round 2 must carry the assistant tool_use turn -// plus a correlated tool_result and return the model's text (not an empty reply). +// TestRun_NativeToolCall_AnthropicAdapter_HTTP 端到端演练真实循环路径: +// 经由真实的 AnthropicChatAdapter 打向一个桩 /v1/messages +// 服务器:第 1 轮流式输出不带文本的 thinking + tool_use 块, +// 循环必须执行它并重新提问,第 2 轮必须携带 assistant tool_use 轮 +// 以及关联的 tool_result,并返回模型的文本(而非空回复)。 func TestRun_NativeToolCall_AnthropicAdapter_HTTP(t *testing.T) { t.Parallel() var mu sync.Mutex diff --git a/internal/runtime/loop/native_toolcall_test.go b/internal/runtime/loop/native_toolcall_test.go index 84fe3f8..009b0ac 100644 --- a/internal/runtime/loop/native_toolcall_test.go +++ b/internal/runtime/loop/native_toolcall_test.go @@ -8,11 +8,11 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// nativeDatetimeCall is a provider-native tool call response: the adapters put -// the call in Response.ToolCalls (not in Content), which is exactly the shape a -// thinking model returns for "what time is it" when the datetime tool is -// advertised. Content is empty, so a loop that ignores ToolCalls produces a -// silent empty reply. +// nativeDatetimeCall 是一个 provider 原生工具调用响应:适配器把 +// 调用放在 Response.ToolCalls(而非 Content)中,这正是 +// 思考模型在 datetime 工具被广告时对「现在几点」返回的 +// 形态。Content 为空,因此忽略 ToolCalls 的循环会产出 +// 静默的空回复。 func nativeDatetimeCall() model.Response { return model.Response{ Model: "m", @@ -25,8 +25,8 @@ func nativeDatetimeCall() model.Response { } } -// assertNativeFeedBack checks that req carries the assistant tool_use turn and a -// correlated tool result for toolCallID. +// assertNativeFeedBack 检查 req 携带 assistant tool_use 轮以及 +// 与 toolCallID 关联的工具结果。 func assertNativeFeedBack(t *testing.T, req model.Request, toolCallID string) { t.Helper() for i := 0; i < len(req.Messages)-1; i++ { @@ -52,10 +52,10 @@ func assertNativeFeedBack(t *testing.T, req model.Request, toolCallID string) { t.Fatalf("no native assistant tool_use + tool_result pair for %q in %+v", toolCallID, req.Messages) } -// TestRun_NativeToolCall_Streaming covers the real chat path (the loop drives -// ChatStream when OnStream is set): a native provider tool call with empty -// Content must be executed and fed back as native tool_use + tool_result, and -// the run must finish with the model's follow-up text instead of an empty reply. +// TestRun_NativeToolCall_Streaming 覆盖真实聊天路径(设置 OnStream 时 +// 循环驱动 ChatStream):Content 为空的 provider 原生工具调用 +// 必须被执行并以原生 tool_use + tool_result 回灌,且 +// run 必须以模型的后续文本结束,而不是空回复。 func TestRun_NativeToolCall_Streaming(t *testing.T) { t.Parallel() ad := &mockAdapter{stream: []mgResp{ @@ -85,9 +85,9 @@ func TestRun_NativeToolCall_Streaming(t *testing.T) { assertNativeFeedBack(t, ad.streamReqs[1], "call_native_1") } -// TestRun_NativeToolCall_NonStreaming covers the muted/internal path where the -// loop delegates to agent.RunStep (adapter.Chat), which must decode the same -// native tool call. +// TestRun_NativeToolCall_NonStreaming 覆盖静音/内部路径,其中 +// 循环委托给 agent.RunStep(adapter.Chat),后者必须解码同一个 +// 原生工具调用。 func TestRun_NativeToolCall_NonStreaming(t *testing.T) { t.Parallel() ad := &mockAdapter{chat: []mgResp{ @@ -111,9 +111,9 @@ func TestRun_NativeToolCall_NonStreaming(t *testing.T) { assertNativeFeedBack(t, ad.reqs[1], "call_native_1") } -// TestExtractToolCalls_PrefersNative pins the decoder contract: provider-native -// ToolCalls win over a Content envelope and preserve the provider id, so the -// feed-back can correlate the result. +// TestExtractToolCalls_PrefersNative 钉住解码器契约:provider 原生 +// ToolCalls 优先于 Content 信封并保留 provider id,使 +// 回灌能关联该结果。 func TestExtractToolCalls_PrefersNative(t *testing.T) { t.Parallel() resp := nativeDatetimeCall() diff --git a/internal/runtime/loop/options.go b/internal/runtime/loop/options.go index a0d25bf..79d11c4 100644 --- a/internal/runtime/loop/options.go +++ b/internal/runtime/loop/options.go @@ -13,166 +13,166 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) -// Options configures a Loop. All fields are optional and nil is tolerated until -// the step that actually needs the field: the agent is required for Run (Run -// returns ErrNilAgent otherwise); a missing tool dispatcher causes a requested -// call to be fed back as an error; a missing retriever/persona skips memory -// injection; a missing extractor/store skips the memory hook; a missing -// checkpoint skips persistence; a missing tracer skips tracing. +// Options 配置一个 Loop。所有字段都是可选的,nil 会一直被容忍到 +// 真正需要该字段的那一步:Run 必须有 agent(否则 +// Run 返回 ErrNilAgent);缺少工具 dispatcher 会使被请求的 +// 调用以错误形式回灌;缺少 retriever/persona 会跳过记忆 +// 注入;缺少 extractor/store 会跳过记忆钩子;缺少 +// checkpoint 会跳过持久化;缺少 tracer 会跳过追踪。 type Options struct { - // Agent is the loop's execution unit: an instruction string plus a backend - // adapter, an optional tool dispatcher and optional guardrails. It is the - // DEFAULT agent: every turn whose role has no entry in Agents (or every turn - // when Agents is unset) runs on it, so its instructions should be the global - // default role's. + // Agent 是循环的执行单元:一个指令字符串,加上后端 + // adapter、可选工具 dispatcher 与可选护栏。它是 + // 默认 agent:角色在 Agents 中没有条目的每个 turn(或 + // Agents 未设置时的每个 turn)都在它上面运行, + // 因此其指令应是全局默认角色的指令。 Agent *agent.Agent - // Agents is the per-role agent pool (design/22 §4.2 方案 A): persona id → the - // agent carrying that role's system prompt. The loop picks the entry for the - // turn's resolved role on every step, so switching a session's role switches the - // prompt without rebuilding the loop (an Agent's instructions are immutable, so - // one agent per role is the way to keep that invariant). All entries normally - // share one dispatcher/guardrail pipeline/adapter — only the instructions differ. - // A missing or nil entry, an unknown role, or an empty pool all fall back to - // Agent. Nil keeps the pre-existing single-role behavior. + // Agents 是按角色 agent 池(design/22 §4.2 方案 A):persona id → 承载 + // 该角色系统提示词的 agent。循环每一步都为该 + // turn 解析出的角色挑选条目,因此切换会话角色即切换 + // 提示词,而无需重建循环(Agent 的指令不可变, + // 因此每角色一个 agent 是维持该不变式的方式)。所有条目通常 + // 共用同一个 dispatcher/护栏管线/adapter —— 只有指令不同。 + // 条目缺失或为 nil、角色未知、池为空,都会回退到 + // Agent。为 nil 时保持既有的单角色行为。 Agents map[string]*agent.Agent - // Tools is the tool dispatcher used to execute model-requested calls. When - // nil the loop falls back to the agent's own dispatcher (if any). + // Tools 是用于执行模型所请求调用的工具 dispatcher。为 + // nil 时循环回退到 agent 自身的 dispatcher(若有)。 Tools *tool.Dispatcher - // Capacity, when non-nil and enabled, filters the tool schemas advertised to - // the model each round to the allowed set for the current capacity state - // (design/14 唤醒式能力渐进 §4): the loop only exposes the allowed set to the - // model — a view, not a hard boundary (Invoke is left unblocked so an - // emergency call still works). Nil (the default) keeps the pre-existing - // behavior: every registered tool is advertised. + // Capacity 非 nil 且启用时,把每轮广告给模型的工具 schema + // 过滤为当前 capacity 状态的允许集 + // (design/14 唤醒式能力渐进 §4):循环只向 + // 模型暴露允许集 —— 是一个视图,不是硬边界(Invoke 不被拦截, + // 因此紧急调用仍可用)。为 nil(默认)时保持既有 + // 行为:每个已注册工具都被广告。 Capacity *capacity.Decider - // OnCapacity, when non-nil, receives capacity_awaken transition events the - // loop observes while deciding a round (design/14 §4), so a caller can - // surface the "能力点亮" progression to the frontend. Nil = no-op. + // OnCapacity 非 nil 时接收循环在判定一轮期间观察到的 + // capacity_awaken 迁移事件(design/14 §4),使调用方能把 + // 「能力点亮」的进展浮出到前端。为 nil = 空操作。 OnCapacity func(capacity.Event) - // OnAudit, when non-nil, receives an AuditEvent every time the loop denies a - // tool call in a restricted (group/stranger) environment (design/11 §3 §8), - // so the rejection lands in the audit trail and the frontend can render the - // small "xx 工具在群聊被拒" note. Nil = the rejection is still fed back to the - // model as a soft not_allowed result, just not recorded to an external sink. + // OnAudit 非 nil 时,循环每次在受限(群聊/陌生人)环境中 + // 拒绝工具调用都会收到一个 AuditEvent(design/11 §3 §8), + // 使该拒绝进入审计轨迹,且前端可以渲染 + // 「xx 工具在群聊被拒」的小字。为 nil 时该拒绝仍会 + // 作为软 not_allowed 结果回灌给模型,只是不记录到外部 sink。 OnAudit func(AuditEvent) - // OnTurnEnd, when non-nil, is called once per settled turn (a Run that - // returned a Result) with the turn's user/assistant text and the persona the - // loop is bound to, so a caller can advance state that follows the persona — - // the emotion machine and affection curve of design/01 §2. Its return value - // is recorded on Result.Mood and carried to the persisted assistant message. - // Nil = the loop reports nothing and Result.Mood stays empty. + // OnTurnEnd 非 nil 时,每个已落定的 turn(返回了 Result 的 + // Run)调用一次,携带该 turn 的用户/assistant 文本以及循环 + // 绑定的角色,使调用方能推进紧随人格的状态 —— + // design/01 §2 的情绪机与好感度曲线。其返回值 + // 记录在 Result.Mood 上并带到持久化的 assistant 消息。 + // 为 nil = 循环不上报任何东西,Result.Mood 保持为空。 OnTurnEnd TurnEndHook - // Memory is the long-term memory store used to persist extracted items. + // Memory 是用于持久化提取出的条目的长期记忆 store。 Memory *memory.Store - // Retriever injects relevant memories into the context, re-evaluated every - // round (see assembleContext) so retrieval tracks the latest conversation. + // Retriever 把相关记忆注入上下文,每轮重新评估 + //(见 assembleContext),使检索跟上最新对话。 Retriever *memory.Retriever - // WorkingMemory is the per-session sliding window, provided for the - // orchestrator to inspect; the loop does not mutate it. + // WorkingMemory 是每会话滑动窗口,供 + // orchestrator 查看;循环不改写它。 WorkingMemory *memory.WorkingMemory - // Extractor derives memory items to persist after every step. + // Extractor 在每一步之后导出要持久化的记忆条目。 Extractor memory.Extractor - // Checkpoint persists a durable snapshot of the loop state at every step and, - // on a later Run with the same sessionID, resumes an interrupted run from it - // (design §1 durable execution). *persist.Store satisfies the narrow - // checkpointStore interface; nil disables both persistence and resume. + // Checkpoint 在每一步持久化循环状态的持久快照,并在之后 + // 同一 sessionID 的 Run 中据此恢复被中断的 run + //(design §1 durable execution)。*persist.Store 满足这个窄 + // checkpointStore 接口;为 nil 时同时关闭持久化与恢复。 Checkpoint checkpointStore - // Tracer emits a structured span for every step, LLM call and tool call. + // Tracer 为每一步、每次 LLM 调用与每次工具调用发出结构化 span。 Tracer *trace.Tracer - // Flow, when non-nil, accumulates the run's trace spans into a per-session - // flow graph and publishes ws.graph_update on each span transition (design/19 - // §1). Nil disables the realtime flow visualization. + // Flow 非 nil 时,把 run 的 trace span 累积成每会话 + // flow 图,并在每次 span 迁移时发布 ws.graph_update(design/19 + // §1)。为 nil 时关闭实时 flow 可视化。 Flow *FlowPublisher - // Guards validates request/response payloads on the streaming path, where - // RunStep is bypassed. On the non-streaming path the agent runs its own - // guardrails inside RunStep. + // Guards 在流式路径上校验请求/响应载荷 —— 该路径 + // 绕过了 RunStep。非流式路径上由 agent 在 RunStep 内部 + // 运行自己的护栏。 Guards *guardrail.Pipeline - // PersonaID is the loop's configured role: the global default that every turn - // without an explicit SessionEnv.PersonaID runs as (design/22 §2 裁决规则 4). It - // scopes memory retrieval and extraction, the system prompt fallback and the - // emotion hook. Blank skips memory injection/extraction. + // PersonaID 是循环配置的角色:没有显式 SessionEnv.PersonaID 的 + // 每个 turn 都作为它运行(design/22 §2 裁决规则 4)。它 + // 界定记忆检索与提取、系统提示词回退以及 + // 情绪钩子的作用域。为空则跳过记忆注入/提取。 PersonaID string - // PersonaName, when non-nil, resolves a role id to its display name for the - // context's speaker attribution (design/22 §7-5): the role declaration and the - // boundary notes name a speaking role as "「昔涟」(xilian)" instead of a bare id, - // so the model can follow "who said this" without a lookup table of its own. An - // unknown role, an empty name or a nil resolver all fall back to the id — the - // attribution never disappears because a name could not be resolved. + // PersonaName 非 nil 时,为上下文的发言人归属把角色 id + // 解析为其显示名(design/22 §7-5):角色声明与 + // 边界说明把发言角色称作 "「昔涟」(xilian)" 而不是裸 id, + // 使模型无需自备查表就能跟进「谁说的」。未知角色、 + // 空名或 nil 解析器都回退到 id —— 归属绝不 + // 因为名字无法解析而消失。 PersonaName func(personaID string) string - // PersonaKnobs, when non-nil, resolves the reasoning knobs for a role at the - // start of every request build (design/22 §4.2), so a session bound to role Y - // uses Y's thinking budget / effort / broadcast switch rather than the default - // role's. The returned ok=false means "role unknown" and keeps the loop's - // configured defaults (Options.Thinking / ReasoningEffort / ThinkingShowToUser). - // Resolving through the role id — rather than a caller-supplied per-turn value — - // is what keeps every path (gateway, channelbridge, backthink, sub-agents) on the - // right role's knobs from a single input. Nil keeps the configured defaults for - // every turn, i.e. the pre-multi-character behavior. + // PersonaKnobs 非 nil 时,在每次请求构建开始时为角色 + // 解析推理旋钮(design/22 §4.2),使绑定到角色 Y 的会话 + // 使用 Y 的思考预算 / 强度 / 广播开关,而不是默认 + // 角色的。返回的 ok=false 表示「角色未知」并保持循环 + // 配置的默认值(Options.Thinking / ReasoningEffort / ThinkingShowToUser)。 + // 经角色 id 解析 —— 而非由调用方按 turn 传值 —— + // 正是让所有路径(gateway、channelbridge、backthink、子代理)都由单一 + // 输入落到正确角色旋钮的原因。为 nil 时每个 turn 都保持配置的 + // 默认值,即多角色之前的行为。 PersonaKnobs func(personaID string) (LoopKnobs, bool) - // Timezone is the IANA zone used to render the per-round "current time" - // system message (e.g. "Asia/Shanghai"). Empty disables time injection - // entirely (backward compatible); a name that fails to load also disables it. + // Timezone 是用于渲染每轮「当前时间」系统消息的 IANA + // 时区(如 "Asia/Shanghai")。为空则完全关闭时间注入 + // (向后兼容);加载失败的名字同样关闭它。 Timezone string - // MaxRounds is the tool-loop upper bound. Non-positive selects - // DefaultMaxRounds (8). + // MaxRounds 是工具循环上限。非正时选用 + // DefaultMaxRounds(8)。 MaxRounds int - // OnStream, when non-nil, switches the loop to the streaming path: the model - // adapter's ChatStream is driven directly and each structured stream event is - // forwarded to this callback (model.StreamEvent), so the frontend can render - // body text, tool calls and (eventually) reasoning segments independently. + // OnStream 非 nil 时把循环切换到流式路径:直接驱动模型 + // adapter 的 ChatStream,每个结构化流事件都转发到 + // 该回调(model.StreamEvent),使前端能独立渲染 + // 正文文本、工具调用以及(最终)推理分段。 OnStream func(model.StreamEvent) - // Thinking carries the reasoning knobs (budget / explicit switch) injected - // into every model request the loop builds when no per-role value overrides it. - // nil means "do not intervene", which is the pre-existing behavior. It is the - // DEFAULT for turns whose role has no resolved knobs (Options.PersonaKnobs). + // Thinking 携带注入循环构建的每个模型请求的推理旋钮 + //(预算 / 显式开关),在没有按角色取值覆盖它时生效。 + // nil 表示「不干预」,即既有行为。它是 + // 角色没有解析出旋钮的 turn 的默认值(Options.PersonaKnobs)。 Thinking *model.Thinking - // ReasoningEffort is the OpenAI-compatible strength knob injected into every - // model request. Empty means "do not intervene". + // ReasoningEffort 是注入每个模型请求的兼容 OpenAI 的 + // 强度旋钮。为空表示「不干预」。 ReasoningEffort string - // ThinkingShowToUser gates the thinking frames the loop broadcasts: nil (the - // default) keeps the pre-existing behavior and pushes them; an explicit false - // keeps thinking_start/delta/end out of the client stream while text and tool - // frames are unaffected. The frames are still produced and still reach the - // model/transcript paths — only the UI broadcast is gated, so display and - // persistence stay decoupled (design/04 §3.2). + // ThinkingShowToUser 控制循环广播的思考帧:nil(默认) + // 保持既有行为并推送它们;显式 false + // 把 thinking_start/delta/end 挡在客户端流之外,而文本与工具 + // 帧不受影响。这些帧仍被产出并仍抵达 + // 模型/transcript 路径 —— 只有 UI 广播被门控,因此展示与 + // 持久化保持解耦(design/04 §3.2)。 ThinkingShowToUser *bool - // ToolExecution selects how a batch of tool calls is run: "parallel" (the - // default) or "sequential". Empty means parallel. + // ToolExecution 选择一批工具调用如何运行:"parallel"( + // 默认)或 "sequential"。为空表示并行。 ToolExecution string - // ToolParallelism caps how many tool calls run concurrently when ToolExecution - // is "parallel". Non-positive selects DefaultToolParallelism (4). + // ToolParallelism 限制 ToolExecution 为 "parallel" 时并发 + // 运行的工调用数。非正时选用 DefaultToolParallelism(4)。 ToolParallelism int - // Hooks installs optional per-batch/per-call tool-loop hooks. Every field is - // nil-tolerant; these are reserved for "敏感操作代审" (design/11) and the - // future asynchronous-tool path. A zero Hooks value is a no-op. + // Hooks 安装可选的按批次/按调用的工具循环钩子。每个字段都 + // 容忍 nil;这些保留给「敏感操作代审」(design/11)以及 + // 未来的异步工具路径。零值 Hooks 为空操作。 Hooks ToolLoopHooks - // MaxContextTokens is the context budget that triggers overflow compaction. - // Non-positive (the zero value) disables compaction, preserving the loop's - // previous behavior. Setting it to a positive value (e.g. - // DefaultMaxContextTokens) enables compaction: when the loop's estimated - // context exceeds the budget it summarizes older turns into a leading summary - // message while keeping the recent tail (including trailing tool results) - // intact (design §1.3). Nil-tolerant: leaving it unset changes nothing. + // MaxContextTokens 是触发溢出压缩的上下文预算。 + // 非正(零值)关闭压缩,保持循环 + // 此前的行为。设为正值(如 + // DefaultMaxContextTokens)则启用压缩:循环的估算 + // 上下文超出预算时,它把更早的轮次摘要成一条前导摘要 + // 消息,同时保持近期尾部(含尾部工具结果) + // 完整(design §1.3)。容忍 nil:不设置它不改变任何行为。 MaxContextTokens int - // Compactor compacts an over-budget conversation. Nil selects the built-in - // structural compactor (no model call); a non-nil value lets a caller inject - // an LLM-backed or custom summarizer. It is only invoked when - // MaxContextTokens is positive. + // Compactor 压缩超预算的对话。nil 选用内置的 + // 结构化 compactor(不调模型);非 nil 值让调用方注入 + // LLM 支持的或自定义的摘要器。它仅在 + // MaxContextTokens 为正时被调用。 Compactor Compactor - // Vision, when non-nil, rewrites an image-bearing user message at the start - // of Run so the main model can reason over the image (design/06 §0). The - // concrete resolver (vision.MessageResolver) decides via the vision Router - // whether to keep the image for a vision-capable main model or fold it into a - // text description from the vision/OCR tool ("先看图再对话"). Nil keeps the - // image content as-is, passing it through to a vision-capable main model. + // Vision 非 nil 时,在 Run 开始时改写携带图像的用户消息, + // 使主模型能对图像进行推理(design/06 §0)。具体 + // resolver(vision.MessageResolver)通过 vision Router 决定 + // 是保留图像给支持视觉的主模型,还是折叠为 + // vision/OCR 工具产出的文字描述("先看图再对话")。为 nil 时 + // 图像内容原样保留,直接透传给支持视觉的主模型。 Vision ImageResolver - // TransformContext, when non-nil, is called immediately before every model - // step with the assembled message list (after memory injection and any - // compaction) and the current round number (1-based). It may project, filter - // or re-order the messages the model sees. It is the loop's equivalent of - // OpenClaw's transformContext (design §1.3). Nil = no projection. + // TransformContext 非 nil 时,在每个模型步之前立即被调用, + // 传入组装好的消息列表(记忆注入与任何压缩 + // 之后)以及当前轮次号(从 1 开始)。它可以投影、过滤 + // 或重排模型所见的消息。它是循环中与 + // OpenClaw 的 transformContext 等价之物(design §1.3)。为 nil = 不做投影。 TransformContext func(ctx context.Context, messages []model.Message, round int) []model.Message - // Log is the base logger. Nil is replaced with a discard logger tagged with - // source "runtime/loop". + // Log 是基础 logger。为 nil 时替换为标记来源 + // "runtime/loop" 的丢弃 logger。 Log *slog.Logger } diff --git a/internal/runtime/loop/persona.go b/internal/runtime/loop/persona.go index 6918266..8539027 100644 --- a/internal/runtime/loop/persona.go +++ b/internal/runtime/loop/persona.go @@ -7,37 +7,37 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// This file owns the loop's per-turn role resolution (DevDocs/design/22 §4.2): -// one process now hosts several roles at once, so everything that used to be read -// off the Loop's construction-time binding — the system prompt (via the agent), -// the memory namespace, the emotion machine and the reasoning knobs — is resolved -// from the turn's SessionEnv instead. +// 本文件拥有循环的按 turn 角色解析(DevDocs/design/22 §4.2): +// 一个进程现在同时承载多个角色,因此过去从 +// Loop 构造期绑定读取的一切 —— 系统提示词(经 agent)、 +// 记忆命名空间、情绪机与推理旋钮 —— 改为从该 turn 的 +// SessionEnv 解析。 // -// The resolution is deliberately three tiny functions over ctx rather than a -// field on the Loop: the Loop is shared by every concurrent session, so storing a -// resolved role on it would race, while ctx already carries the per-turn env. -// Every role-scoped read must go through these helpers, so prompt, memory, -// emotion and thinking can never disagree about "who is answering". +// 解析刻意做成 ctx 上的三个小函数,而不是 Loop 上的 +// 字段:Loop 由所有并发会话共享,把解析出的角色存在 +// 它上面会产生数据竞争,而 ctx 本就携带按 turn 的 env。 +// 所有角色作用域读取都必须走这些辅助函数,使提示词、记忆、 +// 情绪与思考永远不会对「谁在回答」产生分歧。 -// LoopKnobs are the per-role model/reasoning knobs injected into every request the -// loop builds. It mirrors the three construction-time options -// (Options.Thinking / Options.ReasoningEffort / Options.ThinkingShowToUser) so a -// caller can resolve them per role while the options keep their meaning as the -// default. A nil Thinking and an empty ReasoningEffort mean "do not intervene", -// and a nil ThinkingShowToUser means "broadcast thinking frames as usual". +// LoopKnobs 是注入循环构建的每个请求的按角色模型/推理 +// 旋钮。它镜像三个构造期选项 +// (Options.Thinking / Options.ReasoningEffort / Options.ThinkingShowToUser),使 +// 调用方能按角色解析它们,同时这些选项仍作为 +// 默认值生效。nil Thinking 与空 ReasoningEffort 表示「不干预」, +// nil ThinkingShowToUser 表示「照常广播思考帧」。 type LoopKnobs struct { - // Thinking is the reasoning directive (budget / explicit switch) for the role. + // Thinking 是该角色的推理指令(预算 / 显式开关)。 Thinking *model.Thinking - // ReasoningEffort is the OpenAI-compatible strength knob for the role. + // ReasoningEffort 是该角色兼容 OpenAI 的强度旋钮。 ReasoningEffort string - // ThinkingShowToUser gates the thinking frames broadcast for the role's turns. + // ThinkingShowToUser 控制该角色各 turn 的思考帧是否广播。 ThinkingShowToUser *bool } -// personaFor returns the role the current turn speaks as (design/22 §2): the -// session env's explicit role when the caller resolved one, otherwise the loop's -// configured role (Options.PersonaID, the global default). Both the prompt/memory -// and the emotion hook read it, so a turn can never mix two roles. +// personaFor 返回当前 turn 发言所作为的角色(design/22 §2):调用方 +// 解析出角色时取会话环境的显式角色,否则取循环 +// 配置的角色(Options.PersonaID,全局默认)。提示词/记忆 +// 与情绪钩子都读它,因此一个 turn 绝不会混用两个角色。 func (l *Loop) personaFor(ctx context.Context) string { if pid := sessionEnvFrom(ctx).PersonaID; pid != "" { return pid @@ -45,12 +45,12 @@ func (l *Loop) personaFor(ctx context.Context) string { return l.personaID } -// agentFor returns the agent that carries the current turn's system prompt: the -// per-role agent pool entry when one exists, otherwise the loop's default agent -// (design/22 §4.2 方案 A). A session bound to a role that has no registered agent — -// an unknown/deleted persona, or a caller that wired no pool — falls back to the -// default agent rather than failing the turn, and its memory scope still follows -// personaFor, so the fallback can never silently re-scope memory. +// agentFor 返回承载当前 turn 系统提示词的 agent:存在 +// 按角色 agent 池条目时用它,否则用循环的默认 agent +// (design/22 §4.2 方案 A)。会话绑定到一个没有注册 agent 的角色 —— +// 未知/已删除的人格,或未接任何池的调用方 —— 时回退到 +// 默认 agent 而不是让该 turn 失败,且其记忆作用域仍跟随 +// personaFor,因此该回退绝不会悄悄改变记忆作用域。 func (l *Loop) agentFor(ctx context.Context) *agent.Agent { if len(l.agents) > 0 { if ag := l.agents[l.personaFor(ctx)]; ag != nil { @@ -60,11 +60,11 @@ func (l *Loop) agentFor(ctx context.Context) *agent.Agent { return l.agent } -// knobsFor resolves the reasoning knobs for the current turn: the resolver's -// per-role values when it knows the role, otherwise the loop's configured -// defaults. A resolver that does not recognize the role (ok = false) deliberately -// keeps the defaults rather than zeroing the knobs, so an unregistered id behaves -// like the default role instead of silently disabling reasoning. +// knobsFor 解析当前 turn 的推理旋钮:解析器 +// 认识该角色时用其按角色取值,否则用循环配置的 +// 默认值。不认识该角色的解析器(ok = false)刻意 +// 保留默认值而不是把旋钮清零,使未注册的 id 表现 +// 如同默认角色,而不是悄悄禁用推理。 func (l *Loop) knobsFor(ctx context.Context) LoopKnobs { def := LoopKnobs{ Thinking: l.thinking, diff --git a/internal/runtime/loop/persona_test.go b/internal/runtime/loop/persona_test.go index 38eeb12..4de7d36 100644 --- a/internal/runtime/loop/persona_test.go +++ b/internal/runtime/loop/persona_test.go @@ -11,13 +11,13 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// This file locks the multi-character behavior of design/22 §4.2: prompt, memory -// scope, emotion persona and reasoning knobs must all be resolved from the SAME -// per-turn role, so a session can never answer with role Y's voice but role X's -// memory (design/22 §5 风险表 "提示词/情绪/记忆错配"). +// 本文件锁定 design/22 §4.2 的多角色行为:提示词、记忆 +// 作用域、情绪人格与推理旋钮必须全部从同一个 +// 按 turn 的角色解析,使会话绝不以角色 Y 的声音作答却用角色 X 的 +// 记忆(design/22 §5 风险表 "提示词/情绪/记忆错配")。 -// personaAgent builds an agent whose instructions name the role, so a test can tell -// which pool entry the loop resolved from the leading system message. +// personaAgent 构建一个指令中指明角色的 agent,使测试能从 +// 前导系统消息看出循环解析了哪个池条目。 func personaAgent(t *testing.T, instructions string, ad model.Adapter) *agent.Agent { t.Helper() ag, err := agent.New(instructions, nil, nil, agent.WithModel(ad, nil)) @@ -27,13 +27,13 @@ func personaAgent(t *testing.T, instructions string, ad model.Adapter) *agent.Ag return ag } -// roleEnv is the environment a session resolver produces for personaID: full-access -// owner identity (the identity lane is unchanged) plus the explicitly bound role. +// roleEnv 是会话解析器为 personaID 产出的环境:全权限 +// owner 身份(身份车道不变)加上显式绑定的角色。 func roleEnv(personaID string) SessionEnv { return SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true, PersonaID: personaID} } -// runPersonaTurn drives one turn for sessionID under env and fails the test on error. +// runPersonaTurn 在 env 下为 sessionID 驱动一个 turn,出错即让测试失败。 func runPersonaTurn(t *testing.T, l *Loop, env SessionEnv, sessionID, text string) *Result { t.Helper() res, err := l.Run(WithSessionEnv(context.Background(), env), sessionID, []model.Message{model.TextMessage(model.RoleUser, text)}) @@ -43,7 +43,7 @@ func runPersonaTurn(t *testing.T, l *Loop, env SessionEnv, sessionID, text strin return res } -// lastChatRequest returns the newest request the non-streaming path recorded. +// lastChatRequest 返回非流式路径记录的最新请求。 func lastChatRequest(t *testing.T, ad *mockAdapter) model.Request { t.Helper() if len(ad.reqs) == 0 { @@ -52,7 +52,7 @@ func lastChatRequest(t *testing.T, ad *mockAdapter) model.Request { return ad.reqs[len(ad.reqs)-1] } -// requestContains reports whether any message of req carries want. +// requestContains 报告 req 是否有任何消息携带 want。 func requestContains(req model.Request, want string) bool { for _, m := range req.Messages { if strings.Contains(messageText(m), want) { @@ -62,11 +62,11 @@ func requestContains(req model.Request, want string) bool { return false } -// TestTurnResolvesSystemPromptFromSessionRole is design/22 acceptance 3: switching a -// session's role switches the identity the model is given. The first system message -// must be the bound role's prompt — that is the whole point of the per-role agent -// pool — while an unbound session and an unknown role both stay on the default role -// instead of failing the turn. +// TestTurnResolvesSystemPromptFromSessionRole 是 design/22 验收 3:切换 +// 会话角色即切换给模型的身份。第一条系统消息 +// 必须是所绑定角色的提示词 —— 这正是按角色 agent +// 池的全部意义 —— 而未绑定的会话与未知角色都留在默认 +// 角色上,而不是让该 turn 失败。 func TestTurnResolvesSystemPromptFromSessionRole(t *testing.T) { t.Parallel() ad := &mockAdapter{} @@ -84,26 +84,26 @@ func TestTurnResolvesSystemPromptFromSessionRole(t *testing.T) { t.Fatalf("first system message = %q, want the bound role Y's prompt", got) } - // A session with no explicit binding inherits the loop's configured default. + // 没有显式绑定的会话继承循环配置的默认角色。 runPersonaTurn(t, l, roleEnv(""), "web", "你好") if got := messageText(lastChatRequest(t, ad).Messages[0]); got != "你是角色X。" { t.Fatalf("unbound session prompt = %q, want the default role X's prompt", got) } - // A binding to a role with no pooled agent (deleted/unknown persona) degrades to - // the default agent rather than erroring. + // 绑定到一个没有池中 agent 的角色(已删除/未知人格)会降级到 + // 默认 agent,而不是报错。 runPersonaTurn(t, l, roleEnv("nobody"), "web:z1", "你好") if got := messageText(lastChatRequest(t, ad).Messages[0]); got != "你是角色X。" { t.Fatalf("unknown role prompt = %q, want the default role X's prompt", got) } } -// TestPersonaScopeMemoryIsolationAndExtraction is design/22 acceptance 2 and 5: the -// memory namespace follows the turn's role, so two sessions bound to different roles -// each recall only their own role's memories while two sessions of the SAME role see -// the same memories (口径 1: memory is persona-scoped, not session-scoped); and the -// extracted item is filed under the role that produced the turn — the regression -// guard for the removal of the extractor's construction-time persona. +// TestPersonaScopeMemoryIsolationAndExtraction 是 design/22 验收 2 与 5: +// 记忆命名空间跟随该 turn 的角色,因此绑定到不同角色的两个会话 +// 各自只召回自己角色的记忆,而同一角色的两个会话看到 +// 相同的记忆(口径 1: 记忆按人格作用域,而非按会话作用域);且 +// 提取出的条目归到产出该 turn 的角色之下 —— 这也是 +// 移除 extractor 构造期人格的回归护栏。 func TestPersonaScopeMemoryIsolationAndExtraction(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) @@ -116,15 +116,15 @@ func TestPersonaScopeMemoryIsolationAndExtraction(t *testing.T) { PersonaID: "X", Memory: store, Retriever: memory.NewRetriever(store), - // The extractor is built WITHOUT a persona, exactly as cmd/cyrene builds it: - // the loop is responsible for stamping the turn's role onto each item. + // 该 extractor 构建时不带 persona,与 cmd/cyrene 的构建方式完全一致: + // 循环负责把该 turn 的角色打到每个条目上。 Extractor: memory.NewManualExtractor("", memoryOwnerRoleIDForTest), }) runPersonaTurn(t, l, roleEnv("X"), "web:x1", "记住 我喜欢喝乌龙茶") runPersonaTurn(t, l, roleEnv("Y"), "web:y1", "记住 我讨厌香菜") - // Acceptance 5: each extracted memory is filed under the role of its turn. + // 验收 5:每条提取出的记忆都归到其 turn 的角色之下。 byContent := map[string]string{} for _, it := range listMemories(t, store) { byContent[it.Content] = it.PersonaID @@ -136,7 +136,7 @@ func TestPersonaScopeMemoryIsolationAndExtraction(t *testing.T) { t.Fatalf("memory extracted in a Y turn is filed under %q, want Y (items=%v)", got, byContent) } - // Acceptance 2a: the X session recalls X's memory and never Y's. + // 验收 2a:X 会话召回 X 的记忆,绝不召回 Y 的。 runPersonaTurn(t, l, roleEnv("X"), "web:x1", "乌龙茶怎么样") req := lastChatRequest(t, ad) if !requestContains(req, "我喜欢喝乌龙茶") { @@ -146,14 +146,14 @@ func TestPersonaScopeMemoryIsolationAndExtraction(t *testing.T) { t.Fatalf("X's turn leaked Y's memory: %+v", req.Messages) } - // Acceptance 2b: another session of the SAME role sees it too (memory is - // persona-scoped, so a new session does not start from a blank memory). + // 验收 2b:同一角色的另一个会话也能看到它(记忆按 + // 人格作用域,因此新会话不是从空白记忆开始)。 runPersonaTurn(t, l, roleEnv("X"), "web:x2", "乌龙茶怎么样") if !requestContains(lastChatRequest(t, ad), "我喜欢喝乌龙茶") { t.Fatalf("a second X session did not see X's memory: %+v", lastChatRequest(t, ad).Messages) } - // Acceptance 2c: the Y session recalls only Y's memory. + // 验收 2c:Y 会话只召回 Y 的记忆。 runPersonaTurn(t, l, roleEnv("Y"), "web:y1", "香菜怎么样") req = lastChatRequest(t, ad) if !requestContains(req, "我讨厌香菜") { @@ -164,11 +164,11 @@ func TestPersonaScopeMemoryIsolationAndExtraction(t *testing.T) { } } -// memoryOwnerRoleIDForTest mirrors the composition root's authoring role id, so the -// test builds the extractor the same way production does. +// memoryOwnerRoleIDForTest 镜像组合根的作者角色 id,使该 +// 测试以与生产相同的方式构建 extractor。 const memoryOwnerRoleIDForTest = "owner" -// listMemories returns every stored memory item. +// listMemories 返回每条已存记忆条目。 func listMemories(t *testing.T, store *memory.Store) []memory.MemoryItem { t.Helper() items, err := store.List(context.Background()) @@ -178,9 +178,9 @@ func listMemories(t *testing.T, store *memory.Store) []memory.MemoryItem { return items } -// frameAdapter streams one thinking frame and one text frame, so a test can assert -// both the request the loop built (thinking knobs) and what it broadcast -// (showToUser gate) on the streaming path. +// frameAdapter 流式发出一个思考帧与一个文本帧,使测试能在 +// 流式路径上同时断言循环构建的请求(思考旋钮)与它广播的 +// 内容(showToUser 门)。 type frameAdapter struct { mockAdapter } @@ -194,11 +194,11 @@ func (f *frameAdapter) ChatStream(_ context.Context, req model.Request, on func( return model.Response{Content: "答案"}, nil } -// TestThinkingKnobsFollowTheTurnRole locks the second half of design/22 §4.2: the -// reasoning budget / effort / thinking-broadcast switch that batch A resolved once -// at wiring time must now follow the turn's role, so a session bound to role Y runs -// with Y's budget and Y's broadcast switch instead of the default role's. The -// configured options stay the fallback for an unknown role. +// TestThinkingKnobsFollowTheTurnRole 钉住 design/22 §4.2 的后半:批次 A +// 在接线时一次性解析的推理预算 / 强度 / 思考广播开关,现在 +// 必须跟随该 turn 的角色,因此绑定到角色 Y 的会话以 +// Y 的预算与 Y 的广播开关运行,而不是默认角色的。配置的 +// 选项仍是未知角色的回退值。 func TestThinkingKnobsFollowTheTurnRole(t *testing.T) { t.Parallel() ad := &frameAdapter{} @@ -221,8 +221,8 @@ func TestThinkingKnobsFollowTheTurnRole(t *testing.T) { PersonaKnobs: func(personaID string) (LoopKnobs, bool) { k, ok := knobs[personaID]; return k, ok }, }) - // Role X: its budget reaches the request and its thinking frames are broadcast - // (showToUser unset = the documented "broadcast as usual"). + // 角色 X:其预算到达请求,其思考帧被广播 + // (showToUser 未设置 = 文档所述的「照常广播」)。 runPersonaTurn(t, l, roleEnv("X"), "web:x1", "你好") req := ad.streamReqs[len(ad.streamReqs)-1] if req.Thinking == nil || req.Thinking.BudgetTokens != 100 { @@ -235,9 +235,9 @@ func TestThinkingKnobsFollowTheTurnRole(t *testing.T) { t.Fatalf("X's thinking frames were withheld: %+v", events) } - // Role Y: Y configures no budget and hides thinking frames, so Y's request must - // carry NO thinking directive (the default role's budget must not leak in) and - // only the text frame may reach the client. + // 角色 Y:Y 未配置预算并隐藏思考帧,因此 Y 的请求必须 + // 不携带任何思考指令(默认角色的预算不得渗入),且 + // 只有文本帧可以抵达客户端。 events = nil runPersonaTurn(t, l, roleEnv("Y"), "web:y1", "你好") req = ad.streamReqs[len(ad.streamReqs)-1] @@ -254,7 +254,7 @@ func TestThinkingKnobsFollowTheTurnRole(t *testing.T) { t.Fatal("Y's text frames were suppressed too, want the answer to still stream") } - // An unknown role keeps the loop's configured defaults instead of zeroing them. + // 未知角色保持循环配置的默认值,而不是把它们清零。 events = nil runPersonaTurn(t, l, roleEnv("nobody"), "web:z1", "你好") req = ad.streamReqs[len(ad.streamReqs)-1] @@ -266,7 +266,7 @@ func TestThinkingKnobsFollowTheTurnRole(t *testing.T) { } } -// hasThinkingFrame reports whether the broadcast set carried a reasoning frame. +// hasThinkingFrame 报告广播集是否携带了推理帧。 func hasThinkingFrame(events []model.StreamEvent) bool { for _, ev := range events { if isThinkingKind(ev.Kind) { diff --git a/internal/runtime/loop/request.go b/internal/runtime/loop/request.go index 1316690..44565e8 100644 --- a/internal/runtime/loop/request.go +++ b/internal/runtime/loop/request.go @@ -8,11 +8,11 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// buildRequest assembles a model.Request from the turn's agent's public surface, -// mirroring agent.buildRequest so the streaming path behaves identically to -// RunStep (leading instruction message, tool schemas, model routing). The agent -// and the reasoning knobs are resolved from ctx, so a session that switched roles -// gets the new role's prompt and budget on its very next step (design/22 §4.2). +// buildRequest 从该 turn 的 agent 公开表面组装 model.Request, +// 镜像 agent.buildRequest,使流式路径的行为与 +// RunStep 一致(前导指令消息、工具 schema、模型路由)。agent +// 与推理旋钮从 ctx 解析,因此切换了角色的会话 +// 在其下一步就拿到新角色的提示词与预算(design/22 §4.2)。 func (l *Loop) buildRequest(ctx context.Context, messages []model.Message, ag *agent.Agent, knobs LoopKnobs) model.Request { req := model.Request{ Model: ag.Model(), @@ -32,8 +32,8 @@ func (l *Loop) buildRequest(ctx context.Context, messages []model.Message, ag *a return req } -// assembleMessages prepends the agent's instructions as a leading system message -// when they are non-empty, otherwise returns the input slice unchanged. +// assembleMessages 在 agent 指令非空时把它们作为前导系统消息 +// 前置,否则原样返回输入切片。 func assembleMessages(ag *agent.Agent, messages []model.Message) []model.Message { instr := ag.Instructions() if instr == "" { @@ -45,8 +45,8 @@ func assembleMessages(ag *agent.Agent, messages []model.Message) []model.Message return out } -// temperatureOf returns a pointer to the agent's configured temperature, mapping -// zero to nil so the provider default applies (matching the agent package). +// temperatureOf 返回指向 agent 所配置温度的指针,把 +// 零映射为 nil,使 provider 默认值生效(与 agent 包一致)。 func temperatureOf(ag *agent.Agent) *float64 { t := ag.Temperature() if t == 0 { @@ -55,18 +55,18 @@ func temperatureOf(ag *agent.Agent) *float64 { return &t } -// toolSchemas advertises the registered tool schemas to the model, or nil when no -// dispatcher is configured. When a capacity decider is wired and enabled, it -// classifies the current round and returns only the allowed set for that state -// (a view over the dispatcher, per design/14 §4): the model sees/invokes only -// the allowed tools, while Invoke itself stays unblocked. A nil or disabled -// decider keeps the pre-existing behavior (every tool advertised). +// toolSchemas 向模型广告已注册的工具 schema;未配置 +// dispatcher 时为 nil。接上并启用了 capacity 判定器时,它 +// 判定当前轮次并只返回该状态的允许集 +// (dispatcher 之上的一个视图,见 design/14 §4):模型只看见/调用 +// 允许的工具,而 Invoke 本身不被拦截。nil 或被禁用的 +// 判定器保持既有行为(每个工具都被广告)。 // -// The design/11 §3 security gate runs last over whatever the capacity view left: -// in a group/stranger environment only tools whose editable marker allows that -// environment are advertised (default closed), and the owner environment keeps -// the full set. Security is a hard red line, not a convenience hint, so it can -// only shrink the surface — it never re-admits a tool capacity excluded. +// design/11 §3 安全门最后在 capacity 视图留下的结果之上运行: +// 群聊/陌生人环境中只广告其可编辑标记允许该 +// 环境的工具(默认关闭),而 owner 环境保留 +// 全集。安全是硬红线而非便利提示,因此它 +// 只能收缩暴露面 —— 绝不重新放行被 capacity 排除的工具。 func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []model.ToolSpec { d := l.dispatcher() if d == nil { @@ -110,9 +110,9 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode return l.restrictSchemas(ctx, d, out) } -// capacityTexts extracts the recent conversation text for the capacity decider: -// the last few non-system/non-tool turns, in chronological order (oldest first), -// so the decider's current-turn window sees the latest user message last. +// capacityTexts 为 capacity 判定器提取近期对话文本: +// 最后几个非 system/非 tool 的轮次,按时间顺序(最早的在前), +// 使判定器的当前轮窗口最后看到最新的用户消息。 func capacityTexts(messages []model.Message) []string { texts := make([]string, 0, 6) for i := len(messages) - 1; i >= 0 && len(texts) < 6; i-- { @@ -130,17 +130,17 @@ func capacityTexts(messages []model.Message) []string { return texts } -// capacityRecallTopK bounds how many recalled habit memories the capacity L4 layer -// considers per round. +// capacityRecallTopK 限制 capacity L4 层每轮考虑多少条 +// 召回的习惯记忆。 const capacityRecallTopK = 5 -// capacityRecall retrieves the owner's relevant long-term memories for the capacity -// L4 habit-alignment layer (design §2.2 L4), the memory feed that lets the decider -// learn the user's task-expression habits. It returns nil (keeping L4 silent) when -// no retriever/persona is configured — backward compatible — or in a restricted -// (group chat / stranger) context, per the isolation red line: L4 learns the owner's -// habits and must never read owner-sensitive memory into a group or stranger intent -// decision (design §2.2 红线: 不读陌生人敏感). +// capacityRecall 为 capacity L4 习惯对齐层(design §2.2 L4) +// 检索主人的相关长期记忆,这是让判定器学到用户任务表达 +// 习惯的记忆输入。它返回 nil(使 L4 保持静默)的情形包括: +// 未配置 retriever/persona —— 向后兼容 —— 或处于受限 +// (群聊 / 陌生人)上下文,依据隔离红线:L4 学习主人的 +// 习惯,绝不把主人敏感记忆读进群聊或陌生人的意图 +// 判定(design §2.2 红线: 不读陌生人敏感)。 func (l *Loop) capacityRecall(ctx context.Context, messages []model.Message) []capacity.RecallItem { personaID := l.personaFor(ctx) if l.retriever == nil || personaID == "" { @@ -168,11 +168,11 @@ func (l *Loop) capacityRecall(ctx context.Context, messages []model.Message) []c return items } -// decodeToolCalls decodes tool calls from a model.Response via the agent's shared -// decoder, so the streaming and non-streaming paths agree on the provider-native -// tool calls the adapters emit (Anthropic tool_use / OpenAI tool_calls) as well -// as the legacy JSON-envelope convention. It reports ok=true (with a non-empty -// slice) when a call is decoded. +// decodeToolCalls 经 agent 的共享解码器从 model.Response 解码工具 +// 调用,使流式与非流式路径对适配器发出的 provider 原生 +// 工具调用(Anthropic tool_use / OpenAI tool_calls)以及 +// 旧式 JSON 信封约定保持一致。解码出调用时 +// 报告 ok=true(切片非空)。 func decodeToolCalls(resp model.Response) ([]agent.ToolCall, bool) { return agent.ExtractToolCalls(resp) } diff --git a/internal/runtime/loop/resume_test.go b/internal/runtime/loop/resume_test.go index 0956edd..afe050c 100644 --- a/internal/runtime/loop/resume_test.go +++ b/internal/runtime/loop/resume_test.go @@ -10,10 +10,10 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist" ) -// fakeCheckpointStore is a seam for asserting the resume path without a real -// database. It records every LoadLatest call and returns a scripted checkpoint or -// error, so a test can prove the loop asks the store for the session's checkpoint -// and resumes (or stays fresh) based on what comes back. +// fakeCheckpointStore 是在没有真实数据库的情况下断言恢复路径的 +// 接缝。它记录每次 LoadLatest 调用并返回脚本化的 checkpoint 或 +// 错误,使测试能证明循环向 store 索要该会话的 checkpoint, +// 并据此恢复(或保持全新)。 type fakeCheckpointStore struct { latest *persist.Checkpoint latestErr error @@ -27,12 +27,12 @@ func (f *fakeCheckpointStore) LoadLatest(_ context.Context, agentID string) (*pe return f.latest, f.latestErr } -// TestRun_ResumeFromCheckpoint_ContinuesNoRepeat simulates a long task that was -// interrupted mid-tool-loop: the loop left a tool_calls checkpoint at step 2 with -// the message window as it stood after folding two tool batches in, then the -// process died before step 3 could run. A fresh Run on the same sessionID must -// resume at step 3 — rebuilding the context from the checkpoint rather than -// restarting — and must not re-execute the already-completed steps. +// TestRun_ResumeFromCheckpoint_ContinuesNoRepeat 模拟一个在工具循环中途 +// 被中断的长任务:循环在步 2 留下了 tool_calls checkpoint,其消息 +// 窗口是并入两批工具结果后的样子,随后进程在步 3 能运行前 +// 死亡。同一 sessionID 上全新的 Run 必须从步 3 恢复 —— +// 由该 checkpoint 重建上下文而非重启 —— +// 且不得重跑已完成的步骤。 func TestRun_ResumeFromCheckpoint_ContinuesNoRepeat(t *testing.T) { t.Parallel() ctx := context.Background() @@ -47,8 +47,8 @@ func TestRun_ResumeFromCheckpoint_ContinuesNoRepeat(t *testing.T) { model.TextMessage(model.RoleTool, "tool aa result: va"), model.TextMessage(model.RoleTool, "tool bb result: vb"), } - // Seed exactly what a crashed run leaves behind: a tool_calls checkpoint at - // step 2 with the window computed after step 2's tool batch. + // 精确播种崩溃的 run 留下的东西:步 2 的 tool_calls checkpoint, + // 其窗口在步 2 的工具批次之后算出。 l.saveCheckpoint(ctx, sess, 2, interrupted, agent.StopReasonToolCalls) res, err := l.Run(ctx, sess, []model.Message{model.TextMessage(model.RoleUser, "do the task")}) @@ -56,11 +56,11 @@ func TestRun_ResumeFromCheckpoint_ContinuesNoRepeat(t *testing.T) { t.Fatalf("Run: %v", err) } - // Resume continues at step 3 (the interrupted step), not step 1. + // 恢复从步 3(被中断的那一步)继续,而不是步 1。 if res.Steps != 3 { t.Fatalf("Steps = %d, want 3 (resumed at step 3)", res.Steps) } - // No completed step's tool call is re-executed. + // 已完成步骤的工具调用不会重跑。 if res.ToolCallsExecuted != 0 { t.Fatalf("ToolCallsExecuted = %d, want 0 (completed steps not re-run)", res.ToolCallsExecuted) } @@ -68,8 +68,8 @@ func TestRun_ResumeFromCheckpoint_ContinuesNoRepeat(t *testing.T) { t.Fatalf("Text = %q, want final", res.Text) } - // The resumed run makes exactly one model call (step 3), fed the restored - // window — not a restart with a fresh conversation. + // 恢复后的 run 恰好做一次模型调用(步 3),喂入恢复的 + // 窗口 —— 而不是以全新对话重启。 if len(ad.reqs) != 1 { t.Fatalf("resumed run made %d model calls, want 1", len(ad.reqs)) } @@ -87,9 +87,9 @@ func TestRun_ResumeFromCheckpoint_ContinuesNoRepeat(t *testing.T) { } } -// TestRun_NewSession_NotResumed proves a different sessionID never resurrects an -// old checkpoint: the caller opens a brand-new session, LoadLatest misses, and the -// loop runs fresh from step 1 with the caller's own messages. +// TestRun_NewSession_NotResumed 证明不同的 sessionID 绝不复活 +// 旧的 checkpoint:调用方打开全新会话,LoadLatest 未命中, +// 循环以自己的消息从步 1 全新运行。 func TestRun_NewSession_NotResumed(t *testing.T) { t.Parallel() ctx := context.Background() @@ -103,7 +103,7 @@ func TestRun_NewSession_NotResumed(t *testing.T) { cp := newTestCheckpointStore(t) l := New(Options{Agent: a, Checkpoint: cp, MaxRounds: 8}) - // sess-a holds an interrupted run's checkpoint. + // sess-a 持有一个被中断 run 的 checkpoint。 l.saveCheckpoint(ctx, "sess-a", 2, []model.Message{ model.TextMessage(model.RoleUser, "a task"), model.TextMessage(model.RoleTool, "tool echo result: hi"), @@ -119,8 +119,8 @@ func TestRun_NewSession_NotResumed(t *testing.T) { if res.ToolCallsExecuted != 1 { t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted) } - // The first model call must start from the caller's fresh message, never from - // sess-a's restored window. + // 第一次模型调用必须从调用方的新消息开始,绝不从 + // sess-a 恢复的窗口开始。 first := ad.reqs[0].Messages if len(first) != 2 { t.Fatalf("fresh run first request messages = %d, want 2 (system + user)", len(first)) @@ -130,9 +130,9 @@ func TestRun_NewSession_NotResumed(t *testing.T) { } } -// TestRun_Resume_LoadLatestCalled asserts Run asks the store for the session's -// latest checkpoint and resumes from it (LoadLatest → tool_calls checkpoint → -// continue at the next step). +// TestRun_Resume_LoadLatestCalled 断言 Run 向 store 索要该会话 +// 最新的 checkpoint 并据此恢复(LoadLatest → tool_calls checkpoint → +// 从下一步继续)。 func TestRun_Resume_LoadLatestCalled(t *testing.T) { t.Parallel() ctx := context.Background() @@ -166,9 +166,9 @@ func TestRun_Resume_LoadLatestCalled(t *testing.T) { } } -// TestRun_Resume_NotFound_Fresh asserts LoadLatest is still called on the fresh -// path and that ErrNotFound yields a full restart from step 1 with the caller's -// messages (no checkpoint to resurrect). +// TestRun_Resume_NotFound_Fresh 断言全新路径上仍会调用 LoadLatest, +// 且 ErrNotFound 会以调用方的消息从步 1 完全重启 +// (没有可复活的 checkpoint)。 func TestRun_Resume_NotFound_Fresh(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/internal/runtime/loop/run.go b/internal/runtime/loop/run.go index 0123961..a9a092b 100644 --- a/internal/runtime/loop/run.go +++ b/internal/runtime/loop/run.go @@ -10,26 +10,26 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) -// Run drives the agent loop: assemble a fresh model-visible context every round -// (re-inject relevant memory from the current conversation, optionally compacting -// the older turns when the context exceeds the configured budget), then run up to -// MaxRounds steps. Each step runs the model (streaming through OnStream when -// configured) and any requested tool calls are executed under a per-invoke -// timeout and fed back as RoleTool messages so the model can continue. -// A checkpoint is persisted at every step, every step is traced as a -// state_transition span (with an llm_call child and tool_call spans for each -// invocation), and when the turn settles the memory Extractor runs over the -// turn's conversation (design/03 §1: the LLM extractor on a background -// goroutine, so extraction never delays the reply) with errors logged rather -// than fatal. +// Run 驱动 agent 循环:每轮组装一份全新的模型可见上下文 +// (从当前会话重新注入相关记忆,上下文超出配置预算时 +// 可选地压缩更早的轮次),然后运行至多 +// MaxRounds 个 step。每个 step 运行模型(配置了 OnStream 时 +// 流式),所有被请求的工具调用在每次调用的超时下执行, +// 并以 RoleTool 消息回灌,使模型能继续。 +// 每一步都持久化一个 checkpoint,每一步都作为 +// state_transition span 追踪(每次调用带一个 llm_call 子 span 与 +// tool_call span),且当 turn 落定时记忆 Extractor 在该 +// turn 的对话上运行(design/03 §1:LLM extractor 跑在后台 +// goroutine 上,因此提取绝不延迟回复),错误记录日志 +// 而非致命。 // -// When a turn settles with a Result, the OnTurnEnd hook is called once for that -// turn (never per step) and its mood label is recorded on Result.Mood, so the -// layers around the loop can advance persona state and tag the persisted reply. +// 当 turn 带着 Result 落定时,OnTurnEnd 钩子对该 +// turn 调用一次(绝不按步调用),其情绪标签记录在 Result.Mood 上, +// 使循环外围的分层能推进人格状态并标记持久化的回复。 // -// It returns the final answer, or a partial Result with StopReasonMaxRounds when -// the budget is exhausted without a final answer. Step errors are wrapped with %w -// so callers can classify them with errors.Is. +// 它返回最终答案;预算耗尽而无最终答案时,返回带 +// StopReasonMaxRounds 的部分 Result。step 错误用 %w 包装, +// 使调用方能以 errors.Is 分类它们。 func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Message) (*Result, error) { if ctx == nil { ctx = context.Background() @@ -38,26 +38,26 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa return nil, fmt.Errorf("loop: run: %w", ErrNilAgent) } - // Seed the per-Run capacity accumulator at quiet so the state can progress - // (quiet→awake→full) across a multi-step tool loop; a nil/disabled decider - // leaves it unused. It is scoped to this Run, so concurrent runs never share. - // The session id rides the same context so a capacity_awaken emission can be - // targeted to the session that produced it (design/14 §5). + // 以 quiet 播种该 Run 的 capacity 累加器,使状态能跨多步 + // 工具循环演进(quiet→awake→full);nil/被禁用的判定器 + // 使其不被使用。它作用域限于本次 Run,因此并发 run 绝不共享。 + // 会话 id 搭同一个 context,使 capacity_awaken 的发出能 + // 定向到产出它的会话(design/14 §5)。 ctx = withCapState(ctx, &capStateAccum{state: capacity.StateQuiet}) ctx = withLoopSession(ctx, sessionID) - // Resume an interrupted run (design §1 durable execution): when the session - // has a resumable checkpoint, rebuild its context and continue at the next - // step rather than restarting from a fresh conversation. A different - // sessionID — or a checkpoint left by a completed run — falls through to a - // fresh start (LoadLatest misses, or the stop reason is not resumable). + // 恢复被中断的 run(design §1 durable execution):该会话有 + // 可恢复的 checkpoint 时,重建其上下文并从下一步继续, + // 而不是从全新对话重启。不同的 + // sessionID —— 或已完成 run 留下的 checkpoint —— 落到 + // 全新开始(LoadLatest 未命中,或 stop reason 不可恢复)。 msgs, startStep, _ := l.loadCheckpoint(ctx, sessionID, messages) msgs = l.resolveImages(ctx, msgs) toolState := &toolLoopState{prevFP: make(map[string]bool)} res := &Result{} - // A branch Run (a sub-agent) folds its spans into the parent session's live - // graph; only the owning Run starts and ends a round. Without this a sub-agent - // would reset the frontend's single flowGraph and gap the parent's own nodes. + // 分支 Run(子代理)把其 span 并入父会话的实时 + // 图;只有所属的 Run 开始与结束轮次。没有它,子代理 + // 会重置前端唯一的 flowGraph,并让父级自身的节点出现断档。 if _, isBranch := flowBranch(ctx); l.flow != nil && !isBranch { l.flow.StartRound(sessionID, lastUserText(msgs)) defer func() { l.flow.EndRound(sessionID, res.Text) }() @@ -67,9 +67,9 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa return nil, fmt.Errorf("loop: run: step %d: %w", step, err) } - // Compact older turns when the conversation exceeds the context budget, - // then assemble the model-visible context for this round (fresh memory - // injection + transform hook), so every step sees an up-to-date context. + // 会话超出上下文预算时压缩更早的轮次, + // 然后为本轮组装模型可见上下文(全新记忆 + // 注入 + transform 钩子),使每一步都看到最新上下文。 msgs = l.compactIfNeeded(ctx, msgs) view := l.assembleContext(ctx, msgs, step) @@ -102,8 +102,8 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa res.ToolCallsExecuted += batch.executed l.saveCheckpoint(ctx, sessionID, step, msgs, agent.StopReasonToolCalls) - // The critical-tool-loop safety net (or a terminating hook) stops the - // round without waiting for a real final answer. + // 关键工具循环安全网(或终止钩子)不等真正的最终答案 + // 就停止本轮。 if batch.terminate { res.Text = criticalToolLoopTerminalReason res.StopReason = StopReasonToolLoopTerminated @@ -114,7 +114,7 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa } } - // Budget exhausted without a final answer. + // 预算耗尽而无最终答案。 res.Steps = l.maxRounds res.StopReason = StopReasonMaxRounds l.saveCheckpoint(ctx, sessionID, l.maxRounds, msgs, StopReasonMaxRounds) diff --git a/internal/runtime/loop/session_ctx.go b/internal/runtime/loop/session_ctx.go index a2d8238..83d72c8 100644 --- a/internal/runtime/loop/session_ctx.go +++ b/internal/runtime/loop/session_ctx.go @@ -2,44 +2,44 @@ package loop import "context" -// loopSessionIDKey is the context key for the session id the current Run is -// driving. It is seeded at the top of Run and read where the loop emits a -// session-scoped UI event (capacity_awaken), so the event carries the right -// session rather than being treated as global. +// loopSessionIDKey 是当前 Run 所驱动 session id 的 context key。 +// 它在 Run 顶部播种,并在循环发出会话级 UI 事件 +// (capacity_awaken)处读取,使事件携带正确的会话, +// 而不是被当作全局事件。 type loopSessionIDKey struct{} -// withLoopSession returns a context carrying the Run's session id. +// withLoopSession 返回携带 Run 会话 id 的 context。 func withLoopSession(ctx context.Context, sessionID string) context.Context { return context.WithValue(ctx, loopSessionIDKey{}, sessionID) } -// loopSession returns the session id on ctx, or "" when none was seeded (a -// defensive fallback for a caller that drives the loop outside Run). +// loopSession 返回 ctx 上的 session id;未播种时返回 ""(为在 +// Run 之外驱动循环的调用方提供的防御性兜底)。 func loopSession(ctx context.Context) string { s, _ := ctx.Value(loopSessionIDKey{}).(string) return s } -// SessionFromContext returns the session id the enclosing Run is driving, or "" -// when none was attached. It is exported so a tool executing inside the loop can -// attribute the work it starts (a spawned sub-agent, a background task) to the -// conversation that launched it — never inferred from model output. +// SessionFromContext 返回外层 Run 所驱动的 session id;未挂载时 +// 返回 ""。它导出是为了让循环内执行的工具能把其启动的工作 +// (派生的子代理、后台任务)归属到发起它的会话 —— 绝不 +// 从模型输出推断。 func SessionFromContext(ctx context.Context) string { return loopSession(ctx) } -// loopTurnIDKey is the context key for the assistant turn the current Run is -// producing. The gateway attaches it (WithTurnID) before starting a turn so the -// streaming events the loop emits can be correlated to that turn by the client. +// loopTurnIDKey 是当前 Run 正在产出的 assistant turn 的 context key。 +// gateway 在开始一个 turn 前挂载它(WithTurnID),使循环发出的 +// 流式事件能被客户端关联到该 turn。 type loopTurnIDKey struct{} -// WithTurnID attaches turnID to ctx so the streaming events the loop emits carry -// the turn they belong to. It is exported because the gateway calls it once per -// turn, before Run; a caller that attaches none leaves the events' TurnID empty, -// which keeps the correlation fields backward compatible. +// WithTurnID 把 turnID 挂到 ctx 上,使循环发出的流式事件携带 +// 所属的 turn。它导出是因为 gateway 每个 turn 在 Run 之前调用一次; +// 未挂载的调用方会让事件的 TurnID 为空, +// 从而保持关联字段向后兼容。 func WithTurnID(ctx context.Context, turnID string) context.Context { return context.WithValue(ctx, loopTurnIDKey{}, turnID) } -// loopTurn returns the turn id on ctx, or "" when none was attached. +// loopTurn 返回 ctx 上的 turn id;未挂载时返回 ""。 func loopTurn(ctx context.Context) string { s, _ := ctx.Value(loopTurnIDKey{}).(string) return s diff --git a/internal/runtime/loop/sessionenv.go b/internal/runtime/loop/sessionenv.go index 5bf932e..7d1cc48 100644 --- a/internal/runtime/loop/sessionenv.go +++ b/internal/runtime/loop/sessionenv.go @@ -6,72 +6,72 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" ) -// SessionEnv is the per-conversation context that gates memory injection and -// extraction for one Run call. It travels on the call's context rather than on -// the shared Loop because Loop is reused across concurrent sessions (every -// channel/group/stranger message resolves its own isolation boundary); storing it -// on the Loop struct would race. +// SessionEnv 是每次 Run 调用中控制记忆注入与 +// 提取的按会话上下文。它挂在调用的 context 上而不是 +// 共享的 Loop 上,因为 Loop 会在并发会话间复用(每条 +// channel/群聊/陌生人消息都解析出自己的隔离边界);把它 +// 存在 Loop 结构体上会产生数据竞争。 type SessionEnv struct { - // ActorEnv is the resolved actor environment: owner, group_member, stranger. - // It is the strongly-typed identity lane decided structurally by the channel - // layer (design/11 §0), never inferred from prompt text. + // ActorEnv 是解析出的 actor 环境:owner、group_member、stranger。 + // 它是由 channel 层结构化判定的强类型身份车道 + //(design/11 §0),绝不从提示词文本推断。 ActorEnv restrict.ActorEnv - // MemWritable reports whether this conversation may write shared memory. It is - // true only for the owner's private conversation; group chats and strangers - // are read-only (design/02 §3, design/11 §2). + // MemWritable 报告该会话是否可以写共享记忆。它仅 + // 在主人的私聊中为 true;群聊与陌生人 + // 只读(design/02 §3, design/11 §2)。 MemWritable bool - // PersonaID is the role this conversation speaks as (design/22 §2/§4.2), resolved - // per turn by the caller (normally the gateway's session-binding resolver). Empty - // means "inherit the loop's configured role" (Options.PersonaID, the global default - // role), which keeps every pre-multi-character caller — channelbridge, backthink, - // internal autonomous passes — behaving exactly as before. It scopes the system - // prompt, the memory namespace, the emotion machine and the thinking budget; it is - // NOT an identity or permission input: ActorEnv/MemWritable alone decide access - // (project red line). + // PersonaID 是该会话发言所作为的角色(design/22 §2/§4.2),由调用方 + // 每 turn 解析(通常是 gateway 的会话绑定解析器)。为空 + // 表示「继承循环配置的角色」(Options.PersonaID,全局默认 + // 角色),这使所有多角色之前的调用方 —— channelbridge、backthink、 + // 内部自主流程 —— 行为与从前完全一致。它界定系统 + // 提示词、记忆命名空间、情绪机与思考预算的作用域;它 + // 不是身份或权限输入:仅由 ActorEnv/MemWritable 决定访问 + // (项目红线)。 PersonaID string } -// Restricted reports whether this is a group-chat or stranger conversation, i.e. -// the read-only memory isolation context. Because MemWritable is true only for -// the owner's private conversation, !MemWritable is exactly the restricted set. +// Restricted 报告这是否为群聊或陌生人会话,即 +// 只读记忆隔离上下文。由于 MemWritable 仅在 +// 主人的私聊中为 true,!MemWritable 恰好就是受限集合。 func (e SessionEnv) Restricted() bool { return !e.MemWritable } -// OwnerEnv returns the full-access owner session environment (private -// conversation, memory read+write). It is the Web main-session and internal -// automated-task default, matching the pre-existing owner-full behavior. It binds -// no explicit role (PersonaID is empty), so the loop falls back to its configured -// default role — the caller that knows the session's role sets PersonaID on the -// returned value (design/22 §4.2). +// OwnerEnv 返回全权限的主人会话环境(私聊, +// 记忆读写)。它是 Web 主会话与内部 +// 自动化任务的默认值,与既有的 owner 全权限行为一致。它不绑定 +// 显式角色(PersonaID 为空),因此循环回退到其配置的 +// 默认角色 —— 知道会话角色的调用方在返回值上 +// 设置 PersonaID(design/22 §4.2)。 func OwnerEnv() SessionEnv { return SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true} } -// defaultSessionEnv is the owner-full environment used when a caller attaches no -// explicit env: full memory read and write, the pre-existing behavior. +// defaultSessionEnv 是调用方未挂载显式 env 时使用的 +// owner 全权限环境:记忆全读写,即既有行为。 func defaultSessionEnv() SessionEnv { return OwnerEnv() } type sessionEnvKey struct{} -// WithSessionEnv attaches env to ctx so Run's memory gates honor the -// group/stranger read-only isolation per call. It is concurrency-safe: the env -// travels with the call's context and is never stored on the shared Loop. +// WithSessionEnv 把 env 挂到 ctx 上,使 Run 的记忆门按调用遵循 +// 群聊/陌生人只读隔离。它是并发安全的:env +// 随调用的 context 传递,绝不存放在共享的 Loop 上。 func WithSessionEnv(ctx context.Context, env SessionEnv) context.Context { return context.WithValue(ctx, sessionEnvKey{}, env) } -// SessionEnvFrom returns the environment attached to ctx by WithSessionEnv, or the -// owner-full default when none was attached (backward compatible). It is the read -// side of WithSessionEnv, for a caller that receives only a context — a Chatter -// implementation that must know the turn's role/isolation boundary, or a test -// asserting what an inbound path resolved. The value is display/scope metadata -// only: identity and permission were already decided by the actor metadata before -// the env existed (project red line). +// SessionEnvFrom 返回 WithSessionEnv 挂到 ctx 上的环境; +// 未挂载时返回 owner 全权限默认值(向后兼容)。它是 +// WithSessionEnv 的读侧,供只拿到 context 的调用方使用 —— 例如 +// 必须知道该 turn 角色/隔离边界的 Chatter 实现,或 +// 断言某入站路径解析结果的测试。该值仅是展示/作用域元数据: +// 身份与权限在 env 存在之前就已由 actor 元数据决定 +// (项目红线)。 func SessionEnvFrom(ctx context.Context) SessionEnv { return sessionEnvFrom(ctx) } -// sessionEnvFrom returns the env attached to ctx, or the owner-full default when -// none was attached (backward compatible). +// sessionEnvFrom 返回挂到 ctx 上的 env;未挂载时返回 +// owner 全权限默认值(向后兼容)。 func sessionEnvFrom(ctx context.Context) SessionEnv { if ctx == nil { return defaultSessionEnv() diff --git a/internal/runtime/loop/spanlog.go b/internal/runtime/loop/spanlog.go index 6ec90ef..164f6d9 100644 --- a/internal/runtime/loop/spanlog.go +++ b/internal/runtime/loop/spanlog.go @@ -8,26 +8,26 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) -// Log event types recorded on a span so the flow graph's node log[] shows what -// happened at each step (design/19 §1 节点实时日志). An event is appended only when -// the step really finished, so the log never announces work that did not run. +// 记录在 span 上的日志事件类型,使 flow 图的节点 log[] 展示 +// 每一步发生了什么(design/19 §1 节点实时日志)。仅当该步真正 +// 完成时才追加事件,因此日志绝不宣告未运行的工作。 const ( - // EventLLMDone is recorded on an llm_call span once the model call returned. + // EventLLMDone 在模型调用返回后记录到 llm_call span 上。 EventLLMDone = "llm.done" - // EventToolDone is recorded on a tool_call span once the tool call settled. + // EventToolDone 在工具调用落定后记录到 tool_call span 上。 EventToolDone = "tool.done" ) -// maxLogSummaryRunes bounds a log value before publication. The graph redacts and -// truncates again on the way out; this only keeps an individual span small. +// maxLogSummaryRunes 在发布前限制日志取值的长度。图在 +// 输出时还会再次脱敏与截断;这里只是让单个 span 保持小。 const maxLogSummaryRunes = 240 -// recordLLMDone appends a desensitized summary of a finished model call to its -// span: the model that served the turn, both stop reasons, how many tool calls were -// requested and how long the call took. No prompt or completion text is recorded, -// so an llm node's log can never carry conversation content. ag is the turn's agent -// (the per-role pool entry), so a multi-role process reports the model that actually -// served the call rather than the default role's. +// recordLLMDone 把一次已完成模型调用的脱敏摘要追加到其 +// span:服务该 turn 的模型、两个 stop reason、请求了多少工具调用 +// 以及该调用耗时。不记录任何提示词或补全文本, +// 因此 llm 节点的日志绝不携带对话内容。ag 是该 turn 的 agent +// (按角色池条目),因此多角色进程上报的是实际 +// 服务该调用的模型,而不是默认角色的模型。 func (l *Loop) recordLLMDone(span *trace.Span, out *stepOutcome, ag *agent.Agent) { if span == nil || out == nil { return @@ -46,12 +46,12 @@ func (l *Loop) recordLLMDone(span *trace.Span, out *stepOutcome, ag *agent.Agent _ = span.Record(trace.Event{Type: EventLLMDone, Props: props}) } -// recordToolDone appends a desensitized summary of one tool call to its span: the -// tool name, whether it succeeded, the argument NAMES (never their values), the -// error kind on failure, and a truncated result summary. Argument values are -// deliberately omitted — a tool argument is exactly where a credential, a message -// body or a file path with a secret would appear — and everything recorded here is -// scrubbed again by the graph before publication. +// recordToolDone 把一次工具调用的脱敏摘要追加到其 span:工具 +// 名、是否成功、参数名(绝不记录其值)、失败时的 +// 错误种类,以及截断后的结果摘要。参数值被 +// 刻意省略 —— 工具参数正是凭据、消息正文 +// 或含机密的文件路径会出现的地方 —— 且这里记录的一切 +// 在发布前都会由图再次清洗。 func (l *Loop) recordToolDone(span *trace.Span, tc agent.ToolCall, out ToolOutcome) { if span == nil { return @@ -73,9 +73,9 @@ func (l *Loop) recordToolDone(span *trace.Span, tc agent.ToolCall, out ToolOutco _ = span.Record(trace.Event{Type: EventToolDone, Props: props}) } -// modelName reports the model that served a step: the routed request model when -// the streaming path knows it, else the turn's agent's configured model. It is -// empty when neither is available. +// modelName 报告服务某步的模型:流式路径知道时用路由出的 +// 请求模型,否则用该 turn 的 agent 配置的模型。两者 +// 都不可用时为空。 func modelName(out *stepOutcome, ag *agent.Agent) string { if out.model != "" { return out.model @@ -86,8 +86,8 @@ func modelName(out *stepOutcome, ag *agent.Agent) string { return ag.Model() } -// elapsedMS reports how long a span has been open so far. A span's Duration is -// only stamped by Finish, so a log event recorded before Finish measures it here. +// elapsedMS 报告 span 迄今已打开多久。span 的 Duration 仅由 +// Finish 打上,因此在 Finish 之前记录的日志事件在此测量它。 func elapsedMS(span *trace.Span) int64 { if span == nil || span.StartedAt.IsZero() { return 0 @@ -95,8 +95,8 @@ func elapsedMS(span *trace.Span) int64 { return time.Since(span.StartedAt).Milliseconds() } -// argKeys returns a tool call's argument names in sorted order. Only the names are -// logged; the values stay out of the trace (see recordToolDone). +// argKeys 按排序返回工具调用的参数名。只记录 +// 参数名;值留在 trace 之外(见 recordToolDone)。 func argKeys(args map[string]any) []string { if len(args) == 0 { return nil @@ -109,8 +109,8 @@ func argKeys(args map[string]any) []string { return keys } -// trimSummary shortens a log value to maxLogSummaryRunes runes without splitting a -// multi-byte rune. +// trimSummary 把日志取值缩短到 maxLogSummaryRunes 个 rune, +// 且不切开多字节 rune。 func trimSummary(s string) string { r := []rune(s) if len(r) <= maxLogSummaryRunes { diff --git a/internal/runtime/loop/step.go b/internal/runtime/loop/step.go index 58719ca..e09510f 100644 --- a/internal/runtime/loop/step.go +++ b/internal/runtime/loop/step.go @@ -9,29 +9,29 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) -// stepOutcome is the loop's internal view of one model step, normalized from the -// agent's *agent.StepResult (non-streaming) or the equivalent built by the -// streaming path. +// stepOutcome 是循环对单个模型步的内部视图,由 +// agent 的 *agent.StepResult(非流式)或流式路径构建的等价物 +// 归一化而来。 type stepOutcome struct { final bool text string toolCalls []agent.ToolCall stopReason string - // modelStopReason is the provider's raw stop/finish reason for the model - // call (Anthropic max_tokens / end_turn / tool_use), recorded on the llm_call - // span so a truncation is visible even when the step itself ended normally. + // modelStopReason 是 provider 对该模型调用的原始 stop/finish + // 原因(Anthropic max_tokens / end_turn / tool_use),记录在 llm_call + // span 上,使即使该步本身正常结束,截断也可见。 modelStopReason string - // model is the routed model that served the call, known on the streaming path - // (which builds the request itself) and empty on the agent path. It is - // recorded on the llm_call span's log event. + // model 是服务该调用的路由模型,在流式路径上可知 + //(该路径自行构建请求),在 agent 路径上为空。它 + // 记录在 llm_call span 的日志事件上。 model string } -// step runs a single model call. When an OnStream callback is configured it -// drives the adapter's ChatStream directly, threading deltas to OnStream and -// applying the loop's guardrails; otherwise it delegates to the agent's RunStep, -// which runs the agent's own guardrails and records its own CallRecord. The whole -// call is wrapped in an llm_call span so the M9 panel sees each LLM invocation. +// step 运行单次模型调用。配置了 OnStream 回调时,它 +// 直接驱动适配器的 ChatStream,把增量串给 OnStream 并 +// 应用循环的护栏;否则委托给 agent 的 RunStep, +// 由后者运行 agent 自身的护栏并记录自己的 CallRecord。整个 +// 调用被包在 llm_call span 中,使 M9 面板能看到每次 LLM 调用。 func (l *Loop) step(ctx context.Context, sessionID string, messages []model.Message) (*stepOutcome, error) { _, llmSpan := l.span(ctx, "llm.call", trace.SpanKindLLM) @@ -39,8 +39,8 @@ func (l *Loop) step(ctx context.Context, sessionID string, messages []model.Mess out *stepOutcome err error ) - // Muted (internal autonomous pass) bypasses streaming: it neither streams to - // the UI nor pays the streaming path's extra overhead. Normal chat streams. + // 静音(内部自主流程)绕过流式:它既不流向 + // UI,也不付流式路径的额外开销。正常聊天则流式。 if l.onStream != nil && !isMutedBroadcast(ctx) { out, err = l.streamStep(ctx, messages) } else { @@ -62,8 +62,8 @@ func (l *Loop) step(ctx context.Context, sessionID string, messages []model.Mess return out, nil } -// agentStep delegates a non-streaming step to the turn's agent, which runs input -// and output guardrails, calls the model and decodes any tool calls. +// agentStep 把非流式步委托给该 turn 的 agent,由后者运行输入 +// 与输出护栏、调用模型并解码所有工具调用。 func (l *Loop) agentStep(ctx context.Context, sessionID string, messages []model.Message) (*stepOutcome, error) { sr, err := l.agentFor(ctx).RunStep(ctx, sessionID, messages) if err != nil { diff --git a/internal/runtime/loop/stream.go b/internal/runtime/loop/stream.go index a1dce70..76a942d 100644 --- a/internal/runtime/loop/stream.go +++ b/internal/runtime/loop/stream.go @@ -8,21 +8,21 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// streamStep drives a streaming model call. It is the streaming counterpart of -// agent.RunStep: it builds the request from the agent's public surface, runs the -// loop's input guardrail, streams deltas through OnStream, runs the output -// guardrail and decodes any tool calls. Because RunStep does not expose a stream -// path, this method is owned by the loop; it mirrors the agent's semantics so the -// two behave identically apart from the delta callback. +// streamStep 驱动一次流式模型调用。它是 +// agent.RunStep 的流式对应物:它从 agent 的公开表面构建请求、 +// 运行循环的输入护栏、把增量流经 OnStream、运行输出 +// 护栏并解码所有工具调用。由于 RunStep 不暴露流式 +// 路径,该方法归循环所有;它镜像 agent 的语义,使两者 +// 除增量回调外行为一致。 func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepOutcome, error) { ag := l.agentFor(ctx) adapter := ag.Adapter() if adapter == nil { return nil, fmt.Errorf("loop: stream step: %w", ErrNoAdapter) } - // Resolve the role's agent and reasoning knobs once per step: the same pair is - // used for the request and for the per-delta broadcast gate, so a role switch - // can never apply the new prompt with the old broadcast switch mid-stream. + // 每个 step 解析一次该角色的 agent 与推理旋钮:同一对取值 + // 既用于请求,也用于逐增量的广播门,因此角色切换 + // 绝不会在流中途用旧的广播开关套用新的提示词。 knobs := l.knobsFor(ctx) req := l.buildRequest(ctx, messages, ag, knobs) @@ -37,17 +37,17 @@ func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepO } resp, err := adapter.ChatStream(ctx, req, func(ev model.StreamEvent) { - // Stamp the session/turn identity at the loop boundary: the adapter runs - // below the loop and cannot know which conversation the stream belongs to, - // so the loop fills it from the context it was driven with (design/16 §4.2) - // and the frontend can route the delta to the active session. + // 在循环边界打上 session/turn 身份:适配器运行在 + // 循环之下,无法知道该流属于哪个会话, + // 因此循环用它被驱动的 context 填充(design/16 §4.2), + // 前端便能把增量路由到活跃会话。 ev.SessionID = loopSession(ctx) ev.TurnID = loopTurn(ctx) - // Suppress UI broadcast for internal autonomous passes (backthink), - // whose intermediate reasoning must not leak into the chat stream, and for - // thinking frames when the turn's role turned them off (showToUser: false). - // Both gates are broadcast-only: the response the model produced is - // unchanged, so display and persistence stay decoupled. + // 为内部自主流程(backthink)抑制 UI 广播, + // 其中间推理不得泄露进聊天流;当该 turn 的角色 + // 关闭思考帧时(showToUser: false)也抑制。两处门 + // 都只影响广播:模型产出的响应 + // 不变,因此展示与持久化保持解耦。 if l.onStream != nil && !isMutedBroadcast(ctx) && broadcasts(ev, knobs.ThinkingShowToUser) { l.onStream(ev) } @@ -74,11 +74,11 @@ func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepO modelStopReason: resp.StopReason, model: resp.Model}, nil } -// broadcasts reports whether a streamed event may be pushed to the UI. Only -// thinking frames are ever withheld, and only when the turn's role resolved -// showToUser to false; text, tool-call and tool-progress frames are never -// affected, so turning thinking off cannot change what the client sees of the -// answer itself. +// broadcasts 报告某流式事件是否可推给 UI。只有 +// 思考帧会被扣下,且仅当该 turn 的角色把 +// showToUser 解析为 false 时;文本、工具调用与工具进度帧 +// 从不受影响,因此关闭思考不会改变客户端所见 +// 的答案本身。 func broadcasts(ev model.StreamEvent, showToUser *bool) bool { if !isThinkingKind(ev.Kind) { return true @@ -86,7 +86,7 @@ func broadcasts(ev model.StreamEvent, showToUser *bool) bool { return showToUser == nil || *showToUser } -// isThinkingKind reports whether kind is one of the reasoning segment events. +// isThinkingKind 报告 kind 是否为推理分段事件之一。 func isThinkingKind(kind model.StreamKind) bool { switch kind { case model.StreamKindThinkingStart, model.StreamKindThinkingDelta, model.StreamKindThinkingEnd: diff --git a/internal/runtime/loop/stream_scope_test.go b/internal/runtime/loop/stream_scope_test.go index 59a8661..4bfc3fe 100644 --- a/internal/runtime/loop/stream_scope_test.go +++ b/internal/runtime/loop/stream_scope_test.go @@ -8,8 +8,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// collectStream returns an OnStream callback plus a reader for the events it saw, -// so a test can assert the scope the loop stamped on each emitted event. +// collectStream 返回一个 OnStream 回调,外加一个读取它所见过 +// 事件的 reader,使测试能断言循环给每个发出的事件打上的作用域。 func collectStream() (func(model.StreamEvent), func() []model.StreamEvent) { var mu sync.Mutex var events []model.StreamEvent @@ -26,10 +26,10 @@ func collectStream() (func(model.StreamEvent), func() []model.StreamEvent) { return on, read } -// TestRun_StreamCarriesSessionAndTurn locks the A1 contract: the loop stamps the -// session it was driven for and the turn id attached with WithTurnID on every -// streamed event, so the WS hub can route message.delta/thinking.delta/tool_call.* -// to the right conversation and turn (design/16 §4.2). +// TestRun_StreamCarriesSessionAndTurn 钉住 A1 契约:循环给每个 +// 流式事件打上它被驱动的会话,以及经 WithTurnID 挂载的 turn id, +// 使 WS hub 能把 message.delta/thinking.delta/tool_call.* +// 路由到正确的会话与 turn(design/16 §4.2)。 func TestRun_StreamCarriesSessionAndTurn(t *testing.T) { t.Parallel() ad := &mockAdapter{ @@ -58,9 +58,9 @@ func TestRun_StreamCarriesSessionAndTurn(t *testing.T) { } } -// TestRun_StreamWithoutTurnIDLeavesItEmpty keeps the backward-compatible default: -// a caller that attaches no turn id still gets the session on each event, but an -// empty TurnID — the field's zero value is the old behavior. +// TestRun_StreamWithoutTurnIDLeavesItEmpty 保持向后兼容的默认行为: +// 未挂载 turn id 的调用方在每个事件上仍拿到会话,但 +// TurnID 为空 —— 该字段的零值即旧行为。 func TestRun_StreamWithoutTurnIDLeavesItEmpty(t *testing.T) { t.Parallel() ad := &mockAdapter{ @@ -85,9 +85,9 @@ func TestRun_StreamWithoutTurnIDLeavesItEmpty(t *testing.T) { } } -// TestRun_ToolExecutionCarriesScope covers the loop's other stream emitter: the -// tool_execution_update progress frame must be scoped too, otherwise it would be -// the one frame a multi-session client could not route. +// TestRun_ToolExecutionCarriesScope 覆盖循环的另一个流式发出点: +// tool_execution_update 进度帧也必须带作用域,否则它就会成为 +// 多会话客户端唯一无法路由的帧。 func TestRun_ToolExecutionCarriesScope(t *testing.T) { t.Parallel() ad := &mockAdapter{stream: []mgResp{ diff --git a/internal/runtime/loop/thinking_gate_test.go b/internal/runtime/loop/thinking_gate_test.go index 277fa62..a90cd00 100644 --- a/internal/runtime/loop/thinking_gate_test.go +++ b/internal/runtime/loop/thinking_gate_test.go @@ -7,9 +7,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// thinkingAdapter emits one full thinking segment followed by one text segment, -// and records every request it is handed so a test can assert the loop's -// reasoning knobs actually reached the model call. +// thinkingAdapter 发出一个完整的思考分段,后接一个文本分段, +// 并记录它收到的每个请求,使测试能断言循环的 +// 推理旋钮确实到达了模型调用。 type thinkingAdapter struct { reqs []model.Request } @@ -39,7 +39,7 @@ func (a *thinkingAdapter) ChatStream(_ context.Context, req model.Request, on fu return model.Response{Content: "Hello"}, nil } -// kindsOf returns the event kinds as a set for compact assertions. +// kindsOf 把事件 kind 作为集合返回,便于紧凑断言。 func kindsOf(events []model.StreamEvent) map[model.StreamKind]int { out := make(map[model.StreamKind]int, len(events)) for _, ev := range events { @@ -48,8 +48,8 @@ func kindsOf(events []model.StreamEvent) map[model.StreamKind]int { return out } -// runThinkingTurn drives one streaming turn through a loop configured with the -// given broadcast switch and returns the events OnStream saw. +// runThinkingTurn 用给定广播开关配置的循环驱动一个流式 turn, +// 并返回 OnStream 见过的事件。 func runThinkingTurn(t *testing.T, show *bool) ([]model.StreamEvent, *thinkingAdapter) { t.Helper() ad := &thinkingAdapter{} @@ -66,9 +66,9 @@ func runThinkingTurn(t *testing.T, show *bool) ([]model.StreamEvent, *thinkingAd return streams(), ad } -// TestRun_ThinkingShowToUserFalseWithholdsThinkingFrames is the showToUser=false -// contract: thinking frames never reach the client callback while the body text -// segment is delivered untouched (display is gated, the answer is not). +// TestRun_ThinkingShowToUserFalseWithholdsThinkingFrames 是 showToUser=false +// 的契约:思考帧绝不抵达客户端回调,而正文本 +// 分段原样投递(展示被门控,答案不受影响)。 func TestRun_ThinkingShowToUserFalseWithholdsThinkingFrames(t *testing.T) { t.Parallel() @@ -90,8 +90,8 @@ func TestRun_ThinkingShowToUserFalseWithholdsThinkingFrames(t *testing.T) { } } -// TestRun_ThinkingShowToUserDefaultBroadcastsThinking keeps the pre-existing -// behavior when the option is unset (nil): thinking frames are pushed. +// TestRun_ThinkingShowToUserDefaultBroadcastsThinking 在选项未设置 +// (nil)时保持既有行为:思考帧被推送。 func TestRun_ThinkingShowToUserDefaultBroadcastsThinking(t *testing.T) { t.Parallel() @@ -105,8 +105,8 @@ func TestRun_ThinkingShowToUserDefaultBroadcastsThinking(t *testing.T) { } } -// TestRun_ThinkingShowToUserTrueBroadcastsThinking asserts an explicit true -// behaves like the default. +// TestRun_ThinkingShowToUserTrueBroadcastsThinking 断言显式 true +// 的行为与默认值一致。 func TestRun_ThinkingShowToUserTrueBroadcastsThinking(t *testing.T) { t.Parallel() @@ -117,9 +117,9 @@ func TestRun_ThinkingShowToUserTrueBroadcastsThinking(t *testing.T) { } } -// TestRun_RequestCarriesThinkingKnobs asserts the loop injects its reasoning -// options into every model request it builds, so a persona's thinking config is -// actually applied rather than validated-and-ignored. +// TestRun_RequestCarriesThinkingKnobs 断言循环把它配置的推理 +// 选项注入它构建的每个模型请求,使某人格的思考配置 +// 真正被应用,而不是校验后被忽略。 func TestRun_RequestCarriesThinkingKnobs(t *testing.T) { t.Parallel() diff --git a/internal/runtime/loop/time.go b/internal/runtime/loop/time.go index 0fc1423..2c83316 100644 --- a/internal/runtime/loop/time.go +++ b/internal/runtime/loop/time.go @@ -8,12 +8,12 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// weekdaysCN maps time.Weekday (0=Sunday) to a Chinese weekday label. +// weekdaysCN 把 time.Weekday(0=周日)映射为中文星期标签。 var weekdaysCN = [...]string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"} -// loadTimezone resolves the configured IANA timezone into a *time.Location for -// the per-round time injection. An empty name (or one that fails to load) yields -// nil, disabling injection entirely (backward compatible, graceful degradation). +// loadTimezone 把配置的 IANA 时区解析为 *time.Location,供每轮 +// 时间注入使用。空名(或加载失败的名字)返回 nil, +// 从而完全关闭注入(向后兼容,优雅降级)。 func loadTimezone(name string, log *slog.Logger) (*time.Location, string) { if name == "" { return nil, "" @@ -26,11 +26,11 @@ func loadTimezone(name string, log *slog.Logger) (*time.Location, string) { return loc, name } -// currentTimeBlock renders one per-round "current time" system message so the -// model knows the wall-clock time each round (not a first-turn snapshot). It is -// called from assembleContext only when a timezone was configured. It provides -// the raw clock — whether it is "late and time to sleep" is the persona prompt's -// job, not a hard-coded rule here. +// currentTimeBlock 渲染一条每轮「当前时间」系统消息,使模型 +// 每轮都知道墙上时钟时间(而非首轮快照)。它仅在配置了 +// 时区时由 assembleContext 调用。它只提供原始时钟 —— +// 是否「夜深该睡了」是人格提示词的职责,不是这里的 +// 硬编码规则。 func (l *Loop) currentTimeBlock() model.Message { now := time.Now().In(l.timeLocation) text := fmt.Sprintf("当前时间:%s %s,时区 %s", diff --git a/internal/runtime/loop/tool_history.go b/internal/runtime/loop/tool_history.go index 901a43a..90bbee0 100644 --- a/internal/runtime/loop/tool_history.go +++ b/internal/runtime/loop/tool_history.go @@ -5,12 +5,12 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// appendToolCallHistory renders the assistant tool-call turn followed by every -// tool result, in call order. When every call carries a provider id (native -// Anthropic tool_use / OpenAI tool_calls), the turn is emitted as native -// tool_use + tool_result blocks so the provider can correlate each result to its -// call; a batch without ids (the legacy JSON-envelope convention) keeps the -// plain RoleTool text feed-back shape. +// appendToolCallHistory 按调用顺序渲染 assistant 工具调用轮,其后是每个 +// 工具结果。当每个调用都带 provider id(原生 +// Anthropic tool_use / OpenAI tool_calls)时,该轮以原生 +// tool_use + tool_result 块发出,使 provider 能把每个结果关联到其 +// 调用;不带 id 的批次(旧式 JSON 信封约定)保持 +// 纯 RoleTool 文本回灌形态。 func appendToolCallHistory(messages []model.Message, calls []agent.ToolCall, outcomes []ToolOutcome) []model.Message { native := nativeModelToolCalls(calls) if len(native) == 0 { @@ -27,9 +27,9 @@ func appendToolCallHistory(messages []model.Message, calls []agent.ToolCall, out return messages } -// nativeModelToolCalls converts a batch into model.ToolCall only when every call -// carries a provider id; a batch without ids (the legacy JSON-envelope -// convention) returns nil so the caller keeps the plain-text feed-back shape. +// nativeModelToolCalls 仅在每个调用都带 provider id 时把批次转换为 +// model.ToolCall;不带 id 的批次(旧式 JSON 信封 +// 约定)返回 nil,使调用方保持纯文本回灌形态。 func nativeModelToolCalls(calls []agent.ToolCall) []model.ToolCall { if len(calls) == 0 { return nil diff --git a/internal/runtime/loop/tool_policy.go b/internal/runtime/loop/tool_policy.go index afa8196..47bad72 100644 --- a/internal/runtime/loop/tool_policy.go +++ b/internal/runtime/loop/tool_policy.go @@ -8,30 +8,30 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" ) -// auditToolRejectNotAllowed is the audit "what" for a tool call denied by the -// design/11 §3 security gate in a group/stranger environment. +// auditToolRejectNotAllowed 是群聊/陌生人环境中被 +// design/11 §3 安全门拒绝的工具调用的审计 "what"。 const auditToolRejectNotAllowed = "tool.reject.not_allowed" -// AuditEvent reports a security-gated tool rejection to the audit sink -// (design/11 §3 §8). The loop emits one when it denies a tool call in a -// restricted (group/stranger) environment, so the rejection is auditable and the -// frontend can render the small "xx 工具在群聊被拒" note. It is wired to the -// gateway audit trail via Options.OnAudit. +// AuditEvent 向审计 sink 上报被安全门拦截的工具拒绝 +// (design/11 §3 §8)。循环在受限(群聊/陌生人)环境中拒绝 +// 工具调用时发出一个,使该拒绝可审计,且 +// 前端可以渲染「xx 工具在群聊被拒」的小字。它经 +// Options.OnAudit 接到 gateway 审计轨迹。 type AuditEvent struct { - // Who is the acting actor environment label ("group_member"/"stranger"). + // Who 是实施行为的 actor 环境标签("group_member"/"stranger")。 Who string - // What is the operation, e.g. "tool.reject.not_allowed". + // What 是操作,如 "tool.reject.not_allowed"。 What string - // Result is the outcome detail — the not_allowed hint naming the denied tool. + // Result 是结果详情 —— 指名被拒工具的 not_allowed 提示。 Result string } -// restrictSchemas returns only the tool schemas the session env on ctx may -// invoke, per the design/11 §3 security gate: a group/stranger session may -// invoke only tools whose editable marker allows that environment (default -// closed), while the owner environment keeps the full set. It is the final word -// over the capacity allowed-set view — a security red line, not a convenience -// hint — so it runs last and can only shrink the surface. +// restrictSchemas 仅返回 ctx 上会话环境可调用的工具 schema, +// 依据 design/11 §3 安全门:群聊/陌生人会话只能 +// 调用其可编辑标记允许该环境的工具(默认 +// 关闭),而 owner 环境保留全集。它是对 capacity +// 允许集视图的最终裁决 —— 是安全红线而非便利 +// 提示 —— 因此它最后运行,且只能收缩暴露面。 func (l *Loop) restrictSchemas(ctx context.Context, d *tool.Dispatcher, schemas []model.ToolSpec) []model.ToolSpec { policy := d.PolicyEngine(sessionEnvFrom(ctx).ActorEnv) kept := make([]model.ToolSpec, 0, len(schemas)) @@ -43,10 +43,10 @@ func (l *Loop) restrictSchemas(ctx context.Context, d *tool.Dispatcher, schemas return kept } -// applyToolSecurityGate blocks every call whose tool is not allowed in the -// session env on ctx (design/11 §3). It mutates actions in place, overriding -// each runnable call to a skip carrying the not_allowed hint and emitting an -// AuditEvent for the rejection. A nil receiver or nil dispatcher is a no-op. +// applyToolSecurityGate 拦截所有工具在 ctx 会话环境中 +// 不被允许的调用(design/11 §3)。它就地把 actions 改写, +// 把每个可运行的调用改为携带 not_allowed 提示的跳过, +// 并为该拒绝发出一个 AuditEvent。nil 接收者或 nil dispatcher 为空操作。 func (l *Loop) applyToolSecurityGate(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, actions []toolCallAction) { env := sessionEnvFrom(ctx) policy := d.PolicyEngine(env.ActorEnv) @@ -67,17 +67,17 @@ func (l *Loop) applyToolSecurityGate(ctx context.Context, d *tool.Dispatcher, ca } } -// notAllowedReason is the soft hint fed back to the model when a restricted -// (group/stranger) session invokes a tool whose editable marker denies that -// environment (design/11 §3). It is a soft result — injected into the -// conversation, never a panic or hard crash — so the model can reassess. +// notAllowedReason 是受限(群聊/陌生人)会话调用其可编辑 +// 标记拒绝该环境的工具时回灌给模型的软提示 +// (design/11 §3)。它是软结果 —— 注入对话, +// 绝不 panic 或硬崩溃 —— 使模型能重新评估。 func notAllowedReason(name string) string { return "not_allowed: tool " + name + " is not allowed in a group/stranger conversation" } -// emitAudit forwards an AuditEvent to the configured OnAudit sink. It is a no-op -// when no sink is wired, so the security gate still works (soft not_allowed -// result) in leaf tests that do not attach an audit sink. +// emitAudit 把 AuditEvent 转发给配置的 OnAudit sink。未接线 +// sink 时为空操作,因此在未挂审计 sink 的叶子测试中 +// 安全门仍能工作(软 not_allowed 结果)。 func (l *Loop) emitAudit(ev AuditEvent) { if l.onAudit == nil { return diff --git a/internal/runtime/loop/tool_policy_gating_test.go b/internal/runtime/loop/tool_policy_gating_test.go index 60f7d54..4b20da2 100644 --- a/internal/runtime/loop/tool_policy_gating_test.go +++ b/internal/runtime/loop/tool_policy_gating_test.go @@ -12,9 +12,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" ) -// newPolicyDispatcher builds a fresh registry + dispatcher and registers one -// mock tool per name, returning both so a test can set the group/stranger -// markers on the registry before exercising the design/11 §3 security gate. +// newPolicyDispatcher 构建全新的 registry + dispatcher,并为每个 +// 名字注册一个 mock 工具,两者都返回,使测试能在演练 +// design/11 §3 安全门之前在 registry 上设置群聊/陌生人标记。 func newPolicyDispatcher(t *testing.T, names []string) (*registry.Registry, *tool.Dispatcher) { t.Helper() reg := registry.New() @@ -30,9 +30,9 @@ func newPolicyDispatcher(t *testing.T, names []string) (*registry.Registry, *too return reg, d } -// expectToolNames asserts got carries exactly the tool names in want, any order. -// The registry List iterates a map, so registration order is not preserved; -// a set comparison keeps these assertions order-independent. +// expectToolNames 断言 got 恰好携带 want 中的工具名,顺序不限。 +// registry 的 List 遍历 map,因此注册顺序不保留; +// 集合比较使这些断言与顺序无关。 func expectToolNames(t *testing.T, got []model.ToolSpec, want ...string) { t.Helper() if len(got) != len(want) { @@ -49,16 +49,16 @@ func expectToolNames(t *testing.T, got []model.ToolSpec, want ...string) { } } -// TestToolSchemas_RestrictedEnv_FiltersByMarkers proves a group/stranger session -// is only shown the tools whose editable marker allows that environment, while -// the owner session keeps the full set (design/11 §3). +// TestToolSchemas_RestrictedEnv_FiltersByMarkers 证明群聊/陌生人会话 +// 只被展示其可编辑标记允许该环境的工具,而 +// owner 会话保留全集(design/11 §3)。 func TestToolSchemas_RestrictedEnv_FiltersByMarkers(t *testing.T) { t.Parallel() reg, d := newPolicyDispatcher(t, []string{"groupOnly", "strangerOnly", "both", "ownerOnly"}) mustSetPolicy(t, reg, "groupOnly", registry.ToolPolicy{AllowInGroup: true}) mustSetPolicy(t, reg, "strangerOnly", registry.ToolPolicy{AllowInStranger: true}) mustSetPolicy(t, reg, "both", registry.ToolPolicy{AllowInGroup: true, AllowInStranger: true}) - // ownerOnly stays default-closed (zero marker). + // ownerOnly 保持默认关闭(零标记)。 l := New(Options{Tools: d}) @@ -74,13 +74,13 @@ func TestToolSchemas_RestrictedEnv_FiltersByMarkers(t *testing.T) { expectToolNames(t, owner, "groupOnly", "strangerOnly", "both", "ownerOnly") } -// TestToolSchemas_DefaultClosed_UnmarkedToolHidden proves the defaulted-closed -// marker: an unmarked tool is not advertised to a group/stranger session, so a -// session cannot even see (let alone call) an owner-only capability. +// TestToolSchemas_DefaultClosed_UnmarkedToolHidden 证明默认关闭 +// 标记:未标记的工具不向群聊/陌生人会话广告,因此 +// 会话连看都看不到(更别说调用)主人专属能力。 func TestToolSchemas_DefaultClosed_UnmarkedToolHidden(t *testing.T) { t.Parallel() reg, d := newPolicyDispatcher(t, []string{"secret", "shared"}) - // Only shared is opened to groups; secret is default-closed. + // 只有 shared 对群聊开放;secret 默认关闭。 mustSetPolicy(t, reg, "shared", registry.ToolPolicy{AllowInGroup: true}) l := New(Options{Tools: d}) @@ -89,10 +89,10 @@ func TestToolSchemas_DefaultClosed_UnmarkedToolHidden(t *testing.T) { expectToolNames(t, l.toolSchemas(ctx, nil), "shared") } -// TestRun_Restricted_NotAllowed_BlockedAndAudited proves a group session calling -// an owner-only tool never executes it: the call is fed back as a soft -// not_allowed result (no panic/crash) and an AuditEvent is recorded for the -// rejection. +// TestRun_Restricted_NotAllowed_BlockedAndAudited 证明群聊会话调用 +// 主人专属工具绝不执行它:该调用作为软 not_allowed +// 结果回灌(不 panic/崩溃),并为该拒绝记录一个 +// AuditEvent。 func TestRun_Restricted_NotAllowed_BlockedAndAudited(t *testing.T) { t.Parallel() call := `{"tool_calls":[{"name":"ownerOnly","args":{}}]}` @@ -148,8 +148,8 @@ func TestRun_Restricted_NotAllowed_BlockedAndAudited(t *testing.T) { } } -// TestRun_Restricted_AllowedTool_Executes proves a group session may invoke a -// tool whose AllowInGroup marker is set, and that it runs normally. +// TestRun_Restricted_AllowedTool_Executes 证明群聊会话可以调用 +// 设置了 AllowInGroup 标记的工具,且它正常运行。 func TestRun_Restricted_AllowedTool_Executes(t *testing.T) { t.Parallel() call := `{"tool_calls":[{"name":"shared","args":{"q":"hi"}}]}` @@ -177,8 +177,8 @@ func TestRun_Restricted_AllowedTool_Executes(t *testing.T) { } } -// TestRun_Owner_AllToolsExecute proves the owner environment is unaffected by the -// default-closed marker: the owner may invoke an owner-only (unmarked) tool. +// TestRun_Owner_AllToolsExecute 证明 owner 环境不受 +// 默认关闭标记影响:主人可以调用主人专属(未标记)工具。 func TestRun_Owner_AllToolsExecute(t *testing.T) { t.Parallel() call := `{"tool_calls":[{"name":"ownerOnly","args":{}}]}` @@ -187,7 +187,7 @@ func TestRun_Owner_AllToolsExecute(t *testing.T) { {resp: model.Response{Content: "done"}}, }} _, d := newPolicyDispatcher(t, []string{"ownerOnly"}) - // Default-closed marker (never opened): owner must still be able to call it. + // 默认关闭标记(从未开放):主人仍必须能调用它。 a := newTestAgent(t, ad, d) l := New(Options{Agent: a, MaxRounds: 8}) @@ -204,8 +204,8 @@ func TestRun_Owner_AllToolsExecute(t *testing.T) { } } -// TestRun_Stranger_NotAllowed_Blocked proves the gate also applies to the -// stranger private-chat environment, and that a group-only tool is denied there. +// TestRun_Stranger_NotAllowed_Blocked 证明该门同样适用于 +// 陌生人私聊环境,且仅群聊的工具在那里被拒。 func TestRun_Stranger_NotAllowed_Blocked(t *testing.T) { t.Parallel() call := `{"tool_calls":[{"name":"groupOnly","args":{}}]}` @@ -235,7 +235,7 @@ func TestRun_Stranger_NotAllowed_Blocked(t *testing.T) { } } -// mustSetPolicy applies a ToolPolicy to the named tool, failing the test on error. +// mustSetPolicy 把 ToolPolicy 应用到具名工具,出错即让测试失败。 func mustSetPolicy(t *testing.T, reg *registry.Registry, name string, pol registry.ToolPolicy) { t.Helper() if err := reg.SetPolicy(name, pol); err != nil { diff --git a/internal/runtime/loop/tools.go b/internal/runtime/loop/tools.go index 165d6cd..8011676 100644 --- a/internal/runtime/loop/tools.go +++ b/internal/runtime/loop/tools.go @@ -13,95 +13,95 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) -// ToolExecutionMode tokens accepted by Options.ToolExecution. Empty selects -// toolExecParallel. +// Options.ToolExecution 接受的 ToolExecutionMode 取值。为空时选用 +// toolExecParallel。 const ( toolExecParallel = "parallel" toolExecSequential = "sequential" ) -// criticalToolLoopTerminalReason is surfaced as Result.Text and on every blocked -// call when the critical-tool-loop safety net stops the run. +// criticalToolLoopTerminalReason 在关键工具循环安全网停止 +// run 时,作为 Result.Text 与每个被拦截调用的文本浮出。 const criticalToolLoopTerminalReason = "critical tool-loop detected: an identical tool call was repeated across rounds; the run is stopping" -// ToolCallVeto is returned by a CallAdmit hook to block a single call. +// ToolCallVeto 由 CallAdmit 钩子返回,用于拦截单个调用。 type ToolCallVeto struct { Block bool Reason string } -// ToolOutcome is the normalized result of executing one tool call. It is what an -// after-call / after-outcome hook observes and may rewrite before the loop -// renders it back to the model. +// ToolOutcome 是一次工具调用执行的归一化结果。它正是 +// after-call / after-outcome 钩子所观察、并可能在循环把它 +// 回渲给模型之前改写的东西。 type ToolOutcome struct { - // Result is the value the tool returned (nil on failure or skip). + // Result 是工具返回的值(失败或跳过时为 nil)。 Result any - // Err is non-nil when the call failed to execute; it is nil on success. + // Err 在调用执行失败时非 nil;成功时为 nil。 Err error - // ErrKind classifies a failure as tool.KindArgumentValidation or - // tool.KindExecution (the task's errorKind status classification). Empty on - // success. + // ErrKind 把失败归类为 tool.KindArgumentValidation 或 + // tool.KindExecution(任务 errorKind 状态分类)。成功时为 + // 空。 ErrKind string - // Skipped is true when the call never executed (duplicate / blocked / vetoed). + // Skipped 在调用从未执行(重复 / 被拦截 / 被否决)时为 true。 Skipped bool - // Reason is a human-readable explanation for a skipped call. + // Reason 是被跳过调用的人类可读解释。 Reason string - // Terminate, when set by a hook, signals that the whole round should stop - // once every call in the batch is also marked terminate. + // Terminate 由钩子设置时,表示一旦该批中每个调用都被 + // 标记 terminate,整轮就应停止。 Terminate bool } -// ToolLoopHooks groups the optional per-batch / per-call tool-loop hooks. Every -// field is nil-tolerant. These are the extension points the loop keeps open for -// "敏感操作代审" (design/11 batch admission) and the future async-tool path, so -// the audit layer and the loop are decoupled. +// ToolLoopHooks 归组可选的按批次 / 按调用工具循环钩子。每个 +// 字段都容忍 nil。这些是循环为「敏感操作代审」(design/11 +// 批次准入)以及未来的异步工具路径保留的扩展点,使 +// 审计层与循环解耦。 type ToolLoopHooks struct { - // BatchAdmit is called once per batch before any call executes. Returning - // (false, reason) blocks the whole batch: every call becomes a skipped result - // carrying reason. nil = always admit. + // BatchAdmit 在任何调用执行前每批调用一次。返回 + // (false, reason) 会拦截整批:每个调用都变成携带 reason 的 + // 跳过结果。为 nil = 总是准入。 BatchAdmit func(ctx context.Context, calls []agent.ToolCall) (bool, string) - // CallAdmit is called per call before it executes. A non-nil veto with - // Block=true blocks that single call. nil = always admit. + // CallAdmit 在每个调用执行前对它调用一次。非 nil 且 + // Block=true 的否决会拦截该单个调用。为 nil = 总是准入。 CallAdmit func(ctx context.Context, call agent.ToolCall) *ToolCallVeto - // AfterCall rewrites a call's outcome after the tool executes. A nil return - // keeps the original. It runs before AfterOutcome. + // AfterCall 在工具执行后改写调用的结果。返回 nil + // 则保持原样。它在 AfterOutcome 之前运行。 AfterCall func(ctx context.Context, call agent.ToolCall, outcome ToolOutcome) *ToolOutcome - // AfterOutcome observes/rewrites the finalized outcome (after AfterCall and - // after any skip/block decision) immediately before the result is rendered - // back to the model. nil = keep the outcome. + // AfterOutcome 观察/改写最终结果(在 AfterCall 与 + // 任何跳过/拦截决策之后),就在结果被回渲给 + // 模型之前。为 nil = 保持结果。 AfterOutcome func(ctx context.Context, call agent.ToolCall, outcome ToolOutcome) *ToolOutcome } -// toolRunResult is the outcome of running one tool batch. +// toolRunResult 是运行一个工具批次的结果。 type toolRunResult struct { messages []model.Message executed int terminate bool } -// toolLoopState carries the per-run state of the critical-tool-loop safety net. -// It is scoped to a single Run call, so concurrent runs never share it. +// toolLoopState 携带关键工具循环安全网的每次 run 状态。 +// 它作用域限于单次 Run 调用,因此并发 run 绝不共享它。 type toolLoopState struct { - criticalSeen bool // a critical loop was already reported this run - prevFP map[string]bool // fingerprints seen in the immediately previous batch + criticalSeen bool // 本次 run 已报告过关键循环 + prevFP map[string]bool // 紧邻上一批中见过的指纹 } -// toolCallAction is the execution decision for one call after classification. +// toolCallAction 是一个调用经分类后的执行决策。 type toolCallAction struct { - run bool // execute the call - reason string // when run==false, the human reason fed back to the model + run bool // 执行该调用 + reason string // run==false 时,回灌给模型的人类可读原因 } -// runToolCalls executes a batch of model-requested tool calls. It classifies -// duplicate invocations (the critical-tool-loop safety net), applies the optional -// batch/per-call hooks, runs the surviving calls in parallel (bounded) or -// serially, and feeds every outcome back as a RoleTool message in the original -// call order. It returns the number of calls actually invoked (executed), the -// updated message slice, and whether the round should terminate. +// runToolCalls 执行一批模型请求的工具调用。它分类重复 +// 调用(关键工具循环安全网)、应用可选的 +// 批次/按调用钩子、以并行(有界)或串行方式运行存活的调用, +// 并按原始调用顺序把每个结果作为 RoleTool 消息回灌。 +// 它返回实际调用的数量(executed)、更新后的消息切片, +// 以及该轮是否应终止。 // -// Invocation is delegated to the dispatched tool, which applies the per-tool -// deadline; a missing dispatcher is fed back as an error so the loop never -// panics or crashes on an unhandled call. +// 调用被委托给被分派的工具,由后者应用每工具 +// 截止时间;缺少 dispatcher 时以错误形式回灌,使循环绝不 +// 对未处理的调用 panic 或崩溃。 func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls []agent.ToolCall, st *toolLoopState) (toolRunResult, error) { d := l.dispatcher() res := toolRunResult{messages: messages} @@ -114,16 +114,16 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls res.terminate = true } - // Security gate (design/11 §3): a group/stranger session may invoke only - // tools whose editable marker allows that environment (default closed). A - // disallowed call never executes; it is fed back as a not_allowed soft result - // and recorded to the audit sink. It is the final authority over the batch — - // a later audit hook may tighten but never re-admit a denied tool. + // 安全门(design/11 §3):群聊/陌生人会话只能调用 + // 其可编辑标记允许该环境的工具(默认关闭)。不被允许的 + // 调用绝不执行;它以 not_allowed 软结果回灌 + // 并记录到审计 sink。它是该批的最终权威 —— + // 后续的审计钩子可以收紧,但绝不重新放行被拒的工具。 if d != nil { l.applyToolSecurityGate(ctx, d, calls, actions) } - // BatchAdmit hook (audit / political review): block the whole batch. + // BatchAdmit 钩子(审计 / 政治审查):拦截整批。 if l.hooks.BatchAdmit != nil { if ok, reason := l.hooks.BatchAdmit(ctx, calls); !ok { if reason == "" { @@ -135,7 +135,7 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls } } - // CallAdmit hook: per-call veto over the calls we intend to run. + // CallAdmit 钩子:对我们打算运行的调用逐个否决。 if l.hooks.CallAdmit != nil { for i := range calls { if !actions[i].run { @@ -151,9 +151,9 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls } } - // Decide serial vs parallel: explicit sequential mode, or any call whose tool - // is declared sequential (a single sequential marker makes the whole group - // serial, matching OpenClaw's launchParallelToolCalls). + // 决定串行还是并行:显式串行模式,或任一调用的工具 + // 被声明为串行(单个串行标记即让整组 + // 串行,与 OpenClaw 的 launchParallelToolCalls 一致)。 sequential := l.toolExec == toolExecSequential if !sequential && d != nil { for i := range calls { @@ -187,7 +187,7 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls res.executed += len(idxs) } - // AfterCall hook (only for calls that actually executed). + // AfterCall 钩子(仅对实际执行过的调用)。 if l.hooks.AfterCall != nil { for i := range calls { if outcomes[i].Skipped { @@ -199,7 +199,7 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls } } - // AfterOutcome hook (runs on every finalized outcome, skipped or not). + // AfterOutcome 钩子(对每个最终结果运行,无论是否跳过)。 if l.hooks.AfterOutcome != nil { for i := range calls { if after := l.hooks.AfterOutcome(ctx, calls[i], outcomes[i]); after != nil { @@ -208,31 +208,31 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls } } - // A hook may ask to terminate the round by marking every call terminate - // (OpenClaw's shouldTerminateToolBatch). + // 钩子可以把每个调用都标记为 terminate 来要求终止本轮 + // (OpenClaw 的 shouldTerminateToolBatch)。 if !res.terminate && shouldTerminateBatch(outcomes) { res.terminate = true } - // Render the assistant tool-call turn (native providers require it so the - // following tool_result can be correlated) plus every result, in call order so - // the model sees a stable feed. + // 渲染 assistant 工具调用轮(原生 provider 需要它以让 + // 后续 tool_result 能被关联),后接每个结果,按调用顺序 + // 排列,使模型看到稳定的回灌。 res.messages = appendToolCallHistory(res.messages, calls, outcomes) return res, nil } -// classifyToolBatch partitions a batch into run/skip decisions, enforcing the -// critical-tool-loop safety net: +// classifyToolBatch 把一批调用划分为运行/跳过决策,执行 +// 关键工具循环安全网: // -// - A call identical (same name + arguments) to one earlier in the same batch -// is skipped with a duplicate hint (never executes). -// - A call identical to one from a previous round is a critical-loop repeat: -// the first occurrence blocks the repeated call and sets criticalSeen, so the -// model gets a turn to reassess; a second consecutive occurrence terminates -// the whole run. +// - 与同批中更早的一个调用完全相同(同名 + 同参数)的 +// 调用被跳过并给出重复提示(绝不执行)。 +// - 与上一轮某个调用完全相同的调用是关键的循环重复: +// 第一次出现会拦截该重复调用并置位 criticalSeen,使 +// 模型有一轮重新评估;连续第二次出现则终止 +// 整个 run。 // -// It returns (actions, terminate). When terminate is true every call is blocked -// with criticalToolLoopTerminalReason and the round must end. +// 它返回 (actions, terminate)。terminate 为 true 时每个调用 +// 都以 criticalToolLoopTerminalReason 拦截,且该轮必须结束。 func classifyToolBatch(st *toolLoopState, calls []agent.ToolCall) ([]toolCallAction, bool) { seen := make(map[string]bool, len(calls)) actions := make([]toolCallAction, len(calls)) @@ -240,7 +240,7 @@ func classifyToolBatch(st *toolLoopState, calls []agent.ToolCall) ([]toolCallAct for i, call := range calls { fp := toolFingerprint(call) if seen[fp] { - // Duplicate of a call already in this batch. + // 与同批中已有调用的重复。 if st.prevFP[fp] { actions[i] = toolCallAction{run: false, reason: repeatedCallReason()} repeat = true @@ -251,7 +251,7 @@ func classifyToolBatch(st *toolLoopState, calls []agent.ToolCall) ([]toolCallAct } seen[fp] = true if st.prevFP[fp] { - // Repeated from the previous round — a critical loop. + // 与上一轮的重复 —— 一个关键循环。 actions[i] = toolCallAction{run: false, reason: repeatedCallReason()} repeat = true } else { @@ -259,8 +259,8 @@ func classifyToolBatch(st *toolLoopState, calls []agent.ToolCall) ([]toolCallAct } } - // Remember which fingerprints were live this round for the next round's - // cross-round repeat detection. + // 记住本轮哪些指纹是活跃的,供下一轮的 + // 跨轮重复检测使用。 st.prevFP = seen terminate := false @@ -277,9 +277,9 @@ func classifyToolBatch(st *toolLoopState, calls []agent.ToolCall) ([]toolCallAct return actions, terminate } -// invokeOne executes a single tool call under its per-tool deadline, emitting a -// tool span, streaming tool_execution_update progress and normalizing the -// outcome. +// invokeOne 在其每工具截止时间下执行单个工具调用,发出一个 +// tool span、流式输出 tool_execution_update 进度并归一化 +// 结果。 func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) ToolOutcome { if d == nil { return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution} @@ -305,10 +305,10 @@ func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolC return out } -// emitToolExecution forwards a tool_execution_update progress event to the -// frontend when the loop is on the streaming path (OnStream configured); it is a -// no-op otherwise (muted internal passes skip it too). phase is a short label -// like "start" or "end:error". +// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把 +// tool_execution_update 进度事件转发给前端;否则为空操作 +// (被静音的内部流程也会跳过它)。phase 是一个短标签, +// 如 "start" 或 "end:error"。 func (l *Loop) emitToolExecution(ctx context.Context, name, phase string) { if l.onStream == nil || isMutedBroadcast(ctx) { return @@ -319,11 +319,11 @@ func (l *Loop) emitToolExecution(ctx context.Context, name, phase string) { l.onStream(ev) } -// invokeParallel runs calls at idxs concurrently, capped at the loop's -// parallelism, writing each outcome back into out at its call index so callers -// can render results in the original order. The semaphore bounds concurrency and -// in-flight goroutines release their slot on completion, so a canceled context -// (which makes every Invoke return promptly) cannot leak goroutines. +// invokeParallel 并发运行 idxs 处的调用,上限为循环的 +// 并行度,并把每个结果写回 out 的对应调用索引处,使调用方 +// 能按原始顺序渲染结果。信号量限制并发,在途 +// goroutine 完成时释放其槽位,因此被取消的 context +// (它使每个 Invoke 迅速返回)不会泄露 goroutine。 func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, idxs []int, out []ToolOutcome) { if len(idxs) == 0 { return @@ -342,7 +342,7 @@ func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []a wg.Wait() } -// returned by classifyToolBatch for the two skip reasons. +// 由 classifyToolBatch 为两种跳过原因返回。 func duplicateCallReason() string { return "duplicate call with identical arguments already issued in this batch" } @@ -351,7 +351,7 @@ func repeatedCallReason() string { return "already used with identical arguments in a previous round; do not repeat it" } -// skippedOutcome builds a Skipped outcome carrying the human reason. +// skippedOutcome 构建一个携带人类可读原因的 Skipped 结果。 func skippedOutcome(reason string) ToolOutcome { if reason == "" { reason = "tool call skipped" @@ -359,8 +359,8 @@ func skippedOutcome(reason string) ToolOutcome { return ToolOutcome{Skipped: true, Reason: reason} } -// shouldTerminateBatch reports whether every call in the batch asked to terminate -// (OpenClaw's shouldTerminateToolBatch), so a hook can stop the round. +// shouldTerminateBatch 报告该批中每个调用是否都要求终止 +// (OpenClaw 的 shouldTerminateToolBatch),使钩子能停止本轮。 func shouldTerminateBatch(outcomes []ToolOutcome) bool { if len(outcomes) == 0 { return false @@ -373,9 +373,9 @@ func shouldTerminateBatch(outcomes []ToolOutcome) bool { return true } -// toolFingerprint canonicalizes a tool call (name + args) so identical calls can -// be detected. Args are JSON-marshaled, so equivalent objects with different key -// orders collapse to the same fingerprint; a marshal failure falls back to %v. +// toolFingerprint 规范化一个工具调用(name + args),使相同调用 +// 可被检测。Args 被 JSON 序列化,因此键序不同的等价对象 +// 会塌缩为同一指纹;序列化失败则回退到 %v。 func toolFingerprint(call agent.ToolCall) string { b, err := json.Marshal(call.Args) if err != nil { @@ -384,9 +384,9 @@ func toolFingerprint(call agent.ToolCall) string { return call.Name + "\x00" + string(b) } -// errorKind classifies an invocation error into a tool.Kind* label so the model -// can distinguish an argument-validation failure (re-issue a corrected call) from -// an execution failure. A nil error yields "" (success). +// errorKind 把调用错误归类为 tool.Kind* 标签,使模型 +// 能区分参数校验失败(重新发出修正后的调用)与 +// 执行失败。nil 错误产出 ""(成功)。 func errorKind(err error) string { if err == nil { return "" @@ -401,8 +401,8 @@ func errorKind(err error) string { return tool.KindExecution } -// dispatcher returns the loop's tool dispatcher, falling back to the agent's own -// when the loop was not given one. +// dispatcher 返回循环的工具 dispatcher;循环未被给予时 +// 回退到 agent 自身的。 func (l *Loop) dispatcher() *tool.Dispatcher { if l.tools != nil { return l.tools @@ -413,16 +413,16 @@ func (l *Loop) dispatcher() *tool.Dispatcher { return nil } -// toolResultMessage renders a tool call's outcome as a RoleTool message ready to -// be fed back to the model. Skipped calls carry their reason; failures carry a -// structured {kind,message} so the LLM can react appropriately; success carries -// the value. +// toolResultMessage 把工具调用的结果渲染为可回灌给模型的 +// RoleTool 消息。被跳过的调用携带其原因;失败携带 +// 结构化的 {kind,message},使 LLM 能恰当应对;成功携带 +// 该值。 func toolResultMessage(tc agent.ToolCall, out ToolOutcome) model.Message { return model.Message{Role: model.RoleTool, ToolCallName: tc.Name, Content: []model.Content{{Type: model.ContentTypeText, Text: toolResultText(tc, out)}}} } -// toolResultText renders the text payload of a tool result message. +// toolResultText 渲染工具结果消息的文本载荷。 func toolResultText(tc agent.ToolCall, out ToolOutcome) string { switch { case out.Skipped: @@ -442,9 +442,9 @@ func toolResultText(tc agent.ToolCall, out ToolOutcome) string { } } -// formatToolResult renders a tool return value as text for the model. Strings are -// passed through (an empty string becomes "empty"); everything else is JSON -// encoded, falling back to a plain %v render on an encoding error. +// formatToolResult 把工具返回值渲染为给模型的文本。字符串 +// 直通(空字符串变为 "empty");其他一切都 JSON +// 编码,编码出错时回退到朴素的 %v 渲染。 func formatToolResult(result any) string { switch v := result.(type) { case nil: diff --git a/internal/runtime/loop/trace.go b/internal/runtime/loop/trace.go index f705634..eaaf5a8 100644 --- a/internal/runtime/loop/trace.go +++ b/internal/runtime/loop/trace.go @@ -6,12 +6,12 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) -// span starts a named span of the given kind when a Tracer is configured, else -// returns a nil no-op span. The returned context carries the span as the current -// one so nested spans chain as children. When a FlowPublisher is configured the -// span's flow owner (the branch parent for a sub-agent Run, else the Run's own -// session, both threaded via ctx) is stamped as a prop and a running placeholder -// node is published immediately, so the flow graph shows work in flight. +// span 在配置了 Tracer 时启动一个给定 kind 的具名 span,否则 +// 返回 nil 空操作 span。返回的 context 把该 span 作为当前 span 携带, +// 使嵌套 span 作为子节点串链。配置了 FlowPublisher 时, +// span 的 flow owner(子代理 Run 取分支父级,否则取 Run 自身的 +// session,两者都经 ctx 传递)会被打为 prop,并立即发布一个 +// 运行中的占位节点,使 flow 图能显示在途工作。 func (l *Loop) span(ctx context.Context, name string, kind trace.SpanKind) (context.Context, *trace.Span) { if l.tracer == nil { return ctx, nil @@ -26,9 +26,9 @@ func (l *Loop) span(ctx context.Context, name string, kind trace.SpanKind) (cont return sctx, sp } -// finishSpan closes a span with the given status, ignoring a nil span. After the -// span closes (immutable) the finished record is folded into the flow graph and -// published, flipping the node to its final status. +// finishSpan 以给定状态关闭 span,忽略 nil span。span 关闭 +// (不可变)后,完成记录被并入 flow 图并 +// 发布,把该节点翻转为最终状态。 func (l *Loop) finishSpan(sp *trace.Span, status trace.SpanStatus) { if sp == nil { return @@ -41,8 +41,8 @@ func (l *Loop) finishSpan(sp *trace.Span, status trace.SpanStatus) { } } -// spanProp attaches a structured property to a span, ignoring a nil span and any -// mutation-after-close error. +// spanProp 给 span 附加一个结构化属性,忽略 nil span 以及 +// 关闭后变更所返回的错误。 func (l *Loop) spanProp(sp *trace.Span, key string, value any) { if sp == nil { return diff --git a/internal/runtime/loop/turn.go b/internal/runtime/loop/turn.go index 0b6514f..c62ac04 100644 --- a/internal/runtime/loop/turn.go +++ b/internal/runtime/loop/turn.go @@ -6,48 +6,48 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// This file owns the loop's turn boundary: the point where one user turn has -// settled (a Run produced a Result) and the state that follows the persona — -// emotion and affection, design/01 §2 — is advanced outside the loop. The loop -// itself stays persona- and emotion-agnostic (design/16 §3.3: the engine -// generates text; state and persistence belong to the layers around it), so it -// only reports the turn and records the mood label the hook hands back. +// 本文件拥有循环的 turn 边界:一个用户 turn 落定之处 +// (Run 产出了 Result),以及紧随人格的状态 —— +// 情绪与好感度,design/01 §2 —— 在循环之外推进。循环 +// 本身保持不感知人格与情绪(design/16 §3.3:引擎 +// 生成文本;状态与持久化属于其外围分层),因此它 +// 只上报该 turn 并记录钩子回传的情绪标签。 -// TurnEnd describes one settled turn to the loop's OnTurnEnd hook. It carries -// everything an observer needs without the loop knowing what the observer does. +// TurnEnd 向循环的 OnTurnEnd 钩子描述一个已落定的 turn。它携带 +// 观察者所需的全部信息,而循环无需知道观察者做什么。 type TurnEnd struct { - // SessionID is the conversation the turn belonged to. + // SessionID 是该 turn 所属的会话。 SessionID string - // PersonaID is the role the turn speaks as: the session env's resolved role when - // the caller supplied one, otherwise the loop's configured default - // (design/22 §4.2). It keeps the observer persona-scoped (design/01 §2): the - // emotion machine and affection of that role — and only that role — advance. + // PersonaID 是该 turn 发言所作为的角色:调用方提供了会话环境 + // 解析出的角色时用它,否则用循环配置的默认角色 + // (design/22 §4.2)。它使观察者保持人格作用域(design/01 §2):该角色 + // 的情绪机与好感度 —— 且仅该角色 —— 推进。 PersonaID string - // UserText is the latest user message of the turn ("" when the turn carried - // no user text, e.g. a resumed tool round). + // UserText 是该 turn 最新的用户消息(该 turn 未携带用户文本时 + // 为 "",例如恢复的工具轮)。 UserText string - // AssistantText is the turn's final assistant text. + // AssistantText 是该 turn 最终的 assistant 文本。 AssistantText string - // StopReason classifies how the turn ended. + // StopReason 归类该 turn 如何结束。 StopReason string - // Steps is the number of model steps the turn took. + // Steps 是该 turn 经过的模型步数。 Steps int - // Env is the conversation's resolved isolation environment (owner / group / - // stranger), so a hook can keep a restricted conversation read-only. + // Env 是会话解析出的隔离环境(owner / group / + // stranger),使钩子能让受限会话保持只读。 Env SessionEnv } -// TurnEndHook observes a settled turn and returns the persona's mood label for -// it ("" when the caller tracks no emotion). It runs once per settled turn — not -// once per model step — so a multi-step tool turn applies its effect exactly -// once. It must not block: it runs on the Run path after the answer is complete, -// and its return value is carried on Result.Mood to the persistence layer. +// TurnEndHook 观察一个已落定的 turn 并返回该 turn 的人格情绪标签 +// (调用方不追踪情绪时为 "")。它每个落定的 turn 运行一次 —— 而非 +// 每个模型步一次 —— 因此多步工具 turn 的效果恰好应用 +// 一次。它不得阻塞:它在答案完成后于 Run 路径上运行, +// 其返回值经 Result.Mood 携带至持久化层。 type TurnEndHook func(TurnEnd) string -// settleTurn reports a settled turn to the hook (when wired) and records the -// returned mood label on the result, so the persisted assistant message carries -// the turn's mood (design/16 §3.3). The conversation environment travels on ctx, -// so a group/stranger turn reaches the hook as restricted. +// settleTurn 把落定的 turn 上报给钩子(已接线时),并把 +// 返回的情绪标签记录到结果上,使持久化的 assistant 消息携带 +// 该 turn 的情绪(design/16 §3.3)。会话环境经 ctx 传递, +// 因此群聊/陌生人 turn 以受限身份到达钩子。 func (l *Loop) settleTurn(ctx context.Context, sessionID string, msgs []model.Message, res *Result) { if l.onTurnEnd == nil { return diff --git a/internal/runtime/loop/turn_test.go b/internal/runtime/loop/turn_test.go index 0644d67..f2be523 100644 --- a/internal/runtime/loop/turn_test.go +++ b/internal/runtime/loop/turn_test.go @@ -9,10 +9,10 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// TestRunCallsOnTurnEndOnce locks the turn boundary: a settled turn is reported -// exactly once — with the turn's own user text, the persona the loop is bound to -// and the answer — and the label the hook returns is recorded on Result.Mood so -// the persistence layer can tag the assistant message. +// TestRunCallsOnTurnEndOnce 钉住 turn 边界:一个落定的 turn 恰好 +// 上报一次 —— 携带该 turn 自己的用户文本、循环绑定的角色 +// 以及答案 —— 钩子返回的标签记录在 Result.Mood 上, +// 使持久化层能标记 assistant 消息。 func TestRunCallsOnTurnEndOnce(t *testing.T) { t.Parallel() ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "在的呀♪"}}}} @@ -56,8 +56,8 @@ func TestRunCallsOnTurnEndOnce(t *testing.T) { } } -// TestRunPassesRestrictedEnvToTurnEnd locks that the conversation's isolation -// environment reaches the hook, so a group/stranger turn can be kept read-only. +// TestRunPassesRestrictedEnvToTurnEnd 钉住会话的隔离 +// 环境到达钩子,使群聊/陌生人 turn 能被保持只读。 func TestRunPassesRestrictedEnvToTurnEnd(t *testing.T) { t.Parallel() ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "好的"}}}} @@ -81,10 +81,10 @@ func TestRunPassesRestrictedEnvToTurnEnd(t *testing.T) { } } -// TestRunTurnEndOnceForMultiStepTurn locks "once per turn, not per step": a -// tool-loop turn (model → tool call → final answer) reports its boundary once, -// and the reported user text stays the turn's user message rather than a -// fed-back tool result. +// TestRunTurnEndOnceForMultiStepTurn 钉住「每 turn 一次,而非每步 +// 一次」:一个工具循环 turn(模型 → 工具调用 → 最终答案)只上报其边界 +// 一次,且上报的用户文本仍是该 turn 的用户消息,而不是 +// 回灌的工具结果。 func TestRunTurnEndOnceForMultiStepTurn(t *testing.T) { t.Parallel() call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}` @@ -123,8 +123,8 @@ func TestRunTurnEndOnceForMultiStepTurn(t *testing.T) { } } -// TestRunNoTurnEndOnError locks that a failed turn is not reported: nothing -// settled, so persona state must not advance. +// TestRunNoTurnEndOnError 钉住失败的 turn 不上报:什么都没 +// 落定,因此人格状态不得推进。 func TestRunNoTurnEndOnError(t *testing.T) { t.Parallel() ad := &mockAdapter{chat: []mgResp{{err: errors.New("model down")}}} @@ -143,8 +143,8 @@ func TestRunNoTurnEndOnError(t *testing.T) { } } -// TestRunWithoutTurnEndHook locks the nil-tolerance: an unwired hook leaves the -// mood empty and Run behaves exactly as before. +// TestRunWithoutTurnEndHook 钉住 nil 容忍:未接线的钩子让 +// 情绪保持为空,Run 的行为与从前完全一致。 func TestRunWithoutTurnEndHook(t *testing.T) { t.Parallel() ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "hi"}}}} diff --git a/internal/runtime/loop/vision.go b/internal/runtime/loop/vision.go index bfd716f..6fb04c7 100644 --- a/internal/runtime/loop/vision.go +++ b/internal/runtime/loop/vision.go @@ -6,34 +6,34 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// ImageResolver is the loop's seam to the multimodal vision layer (design/06 -// §0). It is called once at the start of Run for every message that carries an -// image content part, and may rewrite that message so the main model can reason -// over the image. The concrete resolver lives in the vision package and decides, -// via the vision Router, whether to keep the image for a vision-capable main -// model or fold it into a text description produced by the vision/OCR tool ("先 -// 看图再对话"). Nil leaves the image content untouched so it flows straight to a -// vision-capable main model. +// ImageResolver 是循环通向多模态视觉层的接缝(design/06 +// §0)。它在 Run 开始时对每条携带图像内容部件的消息调用一次, +// 并可以改写该消息,使主模型能对图像进行推理。具体 +// resolver 位于 vision 包,它通过 vision Router 决定 +// 是保留图像给支持视觉的主模型,还是折叠为 +// vision/OCR 工具产出的文字描述(「先 +// 看图再对话」)。为 nil 时图像内容保持原样,直接流向 +// 支持视觉的主模型。 type ImageResolver interface { - // Resolve rewrites msg, which carries at least one image content part. It - // returns the (possibly rewritten) message. An error leaves the original - // message in place so the turn still proceeds. + // Resolve 改写 msg,其中至少携带一个图像内容部件。它 + // 返回(可能已改写的)消息。出错时保留原始 + // 消息,使该 turn 仍能继续。 Resolve(ctx context.Context, msg model.Message) (model.Message, error) } -// ImageResolveFunc adapts a function to the ImageResolver interface. +// ImageResolveFunc 把一个函数适配为 ImageResolver 接口。 type ImageResolveFunc func(ctx context.Context, msg model.Message) (model.Message, error) -// Resolve implements ImageResolver. +// Resolve 实现 ImageResolver。 func (f ImageResolveFunc) Resolve(ctx context.Context, msg model.Message) (model.Message, error) { return f(ctx, msg) } -// resolveImages rewrites every image-bearing message in messages using the -// injected resolver. It returns the input slice unchanged when no resolver is -// configured, no image is present, or a message fails to resolve (the failure is -// logged and the original message is kept so the turn still runs). A fresh slice -// is returned when any message changed. +// resolveImages 用注入的 resolver 改写 messages 中每条 +// 携带图像的消息。未配置 resolver、没有图像、或某条消息 +// 解析失败(失败会被记录日志并保留原消息,使该 turn 仍能 +// 运行)时,它原样返回输入切片。任一消息发生变化时 +// 返回一个新切片。 func (l *Loop) resolveImages(ctx context.Context, messages []model.Message) []model.Message { if l.imageResolver == nil { return messages @@ -60,7 +60,7 @@ func (l *Loop) resolveImages(ctx context.Context, messages []model.Message) []mo return out } -// messageHasImage reports whether any content part of msg is an image part. +// messageHasImage 报告 msg 是否有任何内容部件是图像部件。 func messageHasImage(m model.Message) bool { for _, part := range m.Content { if part.Type == model.ContentTypeImage {