- 抽取器零新依赖纯函数:UTF-8 清洗、CSV/TSV 行数列名摘要、64K rune 安全截断;PDF/Office/二进制走哨兵错误诚实拒绝(不引解析库)。 - sidecar:<DataDir>/extracted/<存储名>.txt,首行紧凑 JSON 元数据 + 正文;临时文件 + rename 原子落定;只抽一次(删原文件仍出正文);版本不匹配/坏缓存重抽。 - 装配期 resolveDocuments(resolveImages 同位,每步对模型可见视图注入):清单恒在(解析器缺席/报错也在)、截断带续读游标、失败诚实标记 + status=unreadable、回合继续;文档部件永不抵达编码器(负例钉住)。 - 修 S2 移交的两处静默吞掉:checkpoint 比较键纳入文档引用、compactor token 估算计入文档部件(不是免费的)。 - 主会话独立验收:隔离 worktree(HEAD + 本切片)build/vet 绿、全树 59 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、documents+loop 两包 167 条 PASS;变异独立复现(resolveDocuments 短路 ⇒ 3 条红,含「文档部件仍抵达下游」)。
216 lines
8.3 KiB
Go
216 lines
8.3 KiB
Go
// Package loop 实现 Cyrene v2 运行时默认的 agent 执行引擎
|
||
// (DevDocs/design/04-运行时.md §1)。它拥有 package agent 刻意
|
||
// 不做的那个循环:从记忆组装上下文、驱动模型至多
|
||
// MaxRounds 个 step、在每次调用的超时下执行所有被请求的工具调用、
|
||
// 把工具结果回灌给模型、每步持久化一个 checkpoint、
|
||
// 发出结构化 trace、下发流式增量并运行
|
||
// 记忆提取钩子。
|
||
//
|
||
// 循环以注入依赖而非具体接线来配置,
|
||
// 因此它保持可用 mock 做叶子测试(mock 适配器、mock 护栏与 mock
|
||
// 工具,加上真实的内存 memory/persist store 与带缓冲的 trace 记录器)。
|
||
package loop
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"io"
|
||
"log/slog"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||
)
|
||
|
||
// 界定循环的常量。它们是编译期常量,使运行路径中
|
||
// 不出现魔法数字。
|
||
const (
|
||
// DefaultMaxRounds 是 Options.MaxRounds 非正时使用的
|
||
// 工具循环上限。它与 agent 包自身的默认值一致。
|
||
DefaultMaxRounds = 8
|
||
|
||
// StopReasonMaxRounds 报告循环在达到最终答案前
|
||
// 用尽了轮次预算。
|
||
StopReasonMaxRounds = "max_rounds"
|
||
|
||
// StopReasonToolLoopTerminated 报告关键工具循环安全网在
|
||
// 同一个工具调用跨连续轮次被完全重复后
|
||
// 停止了 run。
|
||
StopReasonToolLoopTerminated = "tool_loop_terminated"
|
||
|
||
// DefaultToolParallelism 限制循环以并行模式运行一批时
|
||
// 并发执行的工具调用数,未配置显式上限时生效。
|
||
DefaultToolParallelism = 4
|
||
)
|
||
|
||
// Loop 返回的哨兵错误。用 errors.Is 检测它们;它们
|
||
// 被直接返回或以 %w 包装,使底层原因始终可用。
|
||
var (
|
||
// ErrNilAgent 在既未配置默认 agent(Options.Agent)
|
||
// 也未配置按角色 agent 池(Options.Agents)时由 Run 返回。
|
||
ErrNilAgent = errors.New("loop: nil agent")
|
||
|
||
// ErrNoAdapter 在 agent 构建时未带 model Adapter 时
|
||
// 由 Run(流式路径)返回。
|
||
ErrNoAdapter = errors.New("loop: no model adapter")
|
||
)
|
||
|
||
// Result 是一次已完成 Run 的结果。
|
||
type Result struct {
|
||
// Text 是最终的 assistant 文本;循环在无最终答案的情况下
|
||
// 耗尽预算时为空。
|
||
Text string
|
||
// Steps 是执行的模型步数。
|
||
Steps int
|
||
// StopReason 归类循环如何结束:一个 agent StopReason* 值或
|
||
// StopReasonMaxRounds。
|
||
StopReason string
|
||
// ToolCallsExecuted 是循环调用的工具调用数,含
|
||
// 失败的那些。
|
||
ToolCallsExecuted int
|
||
// Mood 是该 turn 的人格情绪标签,由
|
||
// OnTurnEnd 钩子返回(未接线钩子时为 "")。持久化层把它
|
||
// 带到 assistant 消息上(design/16 §3.3)。
|
||
Mood string
|
||
// Documents 是本回合**装配期解析到的文档结论**(design/24 §4.4、§6.1):
|
||
// 按服务端存储名去重、保留首次出现顺序,字段形状即 history.DocumentRef
|
||
// (与落库/DTO 同一个结构体)。调用方据此把 status/chars/truncated 落进
|
||
// 用户消息的 document 列或随响应回传,使客户端能看到「这份附件读到了 /
|
||
// 读不到」—— 没有它,抽取失败对客户端就是不可见的。
|
||
Documents []history.DocumentRef
|
||
// docSeen 是 Documents 的去重索引(存储名 → 下标),作用域限于本次
|
||
// Result,绝不跨 Run 共享。
|
||
docSeen map[string]int
|
||
}
|
||
|
||
// Loop 是默认的 agent 执行引擎。它经 New 一次性配置,
|
||
// 只要注入的依赖(agent、dispatcher、store、recorder、tracer)自身
|
||
// 并发安全(按契约它们是),就安全支持并发 Run 调用。
|
||
// Run 把所有循环状态保留在栈上。
|
||
type Loop struct {
|
||
agent *agent.Agent
|
||
agents map[string]*agent.Agent
|
||
tools *tool.Dispatcher
|
||
capacity *capacity.Decider
|
||
onCapacity func(capacity.Event)
|
||
memory *memory.Store
|
||
retriever *memory.Retriever
|
||
working *memory.WorkingMemory
|
||
extractor memory.Extractor
|
||
checkpoint checkpointStore
|
||
tracer *trace.Tracer
|
||
flow *FlowPublisher
|
||
guards *guardrail.Pipeline
|
||
personaID string
|
||
personaName func(personaID string) string
|
||
personaKnobs func(personaID string) (LoopKnobs, bool)
|
||
personaAwakening func(personaID string) capacity.Awakening
|
||
// personaKnowledgeFor 是按会话解析「人格知识」前导块的解析器
|
||
// (Options.PersonaKnowledgeFor);nil 时每轮都不注入任何块。
|
||
personaKnowledgeFor func(SessionEnv) string
|
||
timeLocation *time.Location
|
||
timeZoneName string
|
||
maxRounds int
|
||
toolExec string
|
||
toolParallelism int
|
||
hooks ToolLoopHooks
|
||
onStream func(model.StreamEvent)
|
||
thinking *model.Thinking
|
||
reasoningEffort string
|
||
thinkingShow *bool
|
||
onAudit func(AuditEvent)
|
||
onTurnEnd TurnEndHook
|
||
maxContextTokens int
|
||
compactor Compactor
|
||
transformContext func(context.Context, []model.Message, int) []model.Message
|
||
imageResolver ImageResolver
|
||
documentResolver DocumentResolver
|
||
log *slog.Logger
|
||
}
|
||
|
||
// 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 {
|
||
maxRounds = DefaultMaxRounds
|
||
}
|
||
toolExec := opts.ToolExecution
|
||
if toolExec == "" {
|
||
toolExec = toolExecParallel
|
||
}
|
||
toolParallelism := opts.ToolParallelism
|
||
if toolParallelism <= 0 {
|
||
toolParallelism = DefaultToolParallelism
|
||
}
|
||
compactor := opts.Compactor
|
||
if compactor == nil {
|
||
compactor = defaultCompactor{}
|
||
}
|
||
timeLocation, timeZoneName := loadTimezone(opts.Timezone, sourcedLog(opts.Log))
|
||
return &Loop{
|
||
agent: opts.Agent,
|
||
agents: opts.Agents,
|
||
tools: opts.Tools,
|
||
capacity: opts.Capacity,
|
||
onCapacity: opts.OnCapacity,
|
||
memory: opts.Memory,
|
||
retriever: opts.Retriever,
|
||
working: opts.WorkingMemory,
|
||
extractor: opts.Extractor,
|
||
checkpoint: opts.Checkpoint,
|
||
tracer: opts.Tracer,
|
||
flow: opts.Flow,
|
||
guards: opts.Guards,
|
||
personaID: opts.PersonaID,
|
||
personaName: opts.PersonaName,
|
||
personaKnobs: opts.PersonaKnobs,
|
||
personaAwakening: opts.PersonaAwakening,
|
||
personaKnowledgeFor: opts.PersonaKnowledgeFor,
|
||
timeLocation: timeLocation,
|
||
timeZoneName: timeZoneName,
|
||
maxRounds: maxRounds,
|
||
toolExec: toolExec,
|
||
toolParallelism: toolParallelism,
|
||
hooks: opts.Hooks,
|
||
onStream: opts.OnStream,
|
||
thinking: opts.Thinking,
|
||
reasoningEffort: opts.ReasoningEffort,
|
||
thinkingShow: opts.ThinkingShowToUser,
|
||
onAudit: opts.OnAudit,
|
||
onTurnEnd: opts.OnTurnEnd,
|
||
maxContextTokens: opts.MaxContextTokens,
|
||
compactor: compactor,
|
||
transformContext: opts.TransformContext,
|
||
imageResolver: opts.Vision,
|
||
documentResolver: opts.DocumentResolver,
|
||
log: sourcedLog(opts.Log),
|
||
}
|
||
}
|
||
|
||
// MaxRounds 返回为该循环配置的工具循环上限。
|
||
func (l *Loop) MaxRounds() int { return l.maxRounds }
|
||
|
||
// Agent 返回注入的默认 agent(Options.Agent)。按角色池
|
||
// 经该 turn 的 context 触达,而不是经这个 getter。
|
||
func (l *Loop) Agent() *agent.Agent { return l.agent }
|
||
|
||
// 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))
|
||
}
|
||
return logger.WithSource(l, "runtime/loop")
|
||
}
|