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:
1 parent
355f81d04a
commit
91e8d7a029
44 files changed
+1315
-1315
No files matched your search
@@ -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"}}}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}}}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}},
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}}}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user