docs(runtime): internal/runtime/loop 44 个文件注释改中文(逐行 1:1,代码零字节改动)

主人 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 改动前即未格式化,按铁律保留原样未动。
This commit is contained in:
AskaEth committed 2026-10-03 22:28:21 +08:00
1 parent 355f81d04a
commit 91e8d7a029
44 files changed
+1315 -1315

No files matched your search

+18 -18
View File
@@ -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"}}}}
+58 -58
View File
@@ -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 {
+36 -36
View File
@@ -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"}}}}
+8 -8
View File
@@ -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
+14 -14
View File
@@ -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))
}
+15 -15
View File
@@ -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)
+34 -34
View File
@@ -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:
+62 -62
View File
@@ -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 {
+15 -15
View File
@@ -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")
}
+33 -33
View File
@@ -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 {
+26 -26
View File
@@ -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
+7 -7
View File
@@ -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 {
+52 -52
View File
@@ -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))
+10 -10
View File
@@ -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}},
+6 -6
View File
@@ -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))
+90 -90
View File
@@ -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 {
+25 -25
View File
@@ -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)
+19 -19
View File
@@ -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())
+20 -20
View File
@@ -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()
+11 -11
View File
@@ -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
@@ -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
+17 -17
View File
@@ -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()
+129 -129
View File
@@ -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
}
+34 -34
View File
@@ -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,
+50 -50
View File
@@ -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) {
+37 -37
View File
@@ -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)
}
+28 -28
View File
@@ -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()
+37 -37
View File
@@ -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)
+19 -19
View File
@@ -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
+42 -42
View File
@@ -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()
+28 -28
View File
@@ -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 {
+18 -18
View File
@@ -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 {
+24 -24
View File
@@ -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:
+12 -12
View File
@@ -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{
+16 -16
View File
@@ -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()
+9 -9
View File
@@ -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",
+9 -9
View File
@@ -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
+27 -27
View File
@@ -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
@@ -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 {
+111 -111
View File
@@ -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:
+11 -11
View File
@@ -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
+29 -29
View File
@@ -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
+14 -14
View File
@@ -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"}}}}
+19 -19
View File
@@ -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 {