- 窗口声明:models.json 组级 contextWindow + env LLM_CONTEXT_WINDOW(per-group materialize 播种,0 不写盘、负数报错)。 - 预算三态:CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导;>0 显式覆盖;0|负 关闭压缩(不变)。推导 budget = min(窗口×比例, 窗口−输出保留),比例默认 2/3(CONTEXT_BUDGET_RATIO,1M ⇒ 699050 tokens ≈ 683Ki);输出保留 = max(32768, 该组 maxTokens) 且封顶窗口/2(防配置矛盾时退化成 1 token);窗口未声明 ⇒ 回落 131072 + 恰好一条 WARN。接线在组合根 wire_context.go(唯一决策点)。 - usage/缓存可观测(此前**完全不可观测**):Response 增 CacheHit/CacheMiss/Reasoning tokens + CacheHitRate;解析 OpenAI 兼容(prompt_cache_hit_tokens/prompt_cache_miss_tokens/cached_tokens/reasoning_tokens)与 anthropic(cache_read_input_tokens/cache_creation_input_tokens)两种形状;记录点在适配器 record(换入口不丢成本日志),日志无凭据;单次输入 ≥预算 90% 记 WARN(不阻断)。 - 根治清单「续读」假承诺:抽取期不再按注入预算截断(sidecar 存全文,只受 MaxSidecarBytes=8 MiB 安全上限,ExtractorVersion v1→v2 旧 sidecar 失效重抽);注入预算改在装配期生效 ⇒ ref.Chars/Truncated 是注入侧事实、游标真是继续读的起点;document-read 的 limit 上限改为独立具名常量。 - 顺手修越界:config.go 505 行(超 500)拆出 llm.go。 - 主会话独立验收:隔离 worktree(HEAD + 本切片 39 文件,排除在途 gateway)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿;变异独立复现(比例改 0.25 ⇒ 推导用例红 262144 vs 699050)。
228 lines
8.8 KiB
Go
228 lines
8.8 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
|
||
documentInjectChars int
|
||
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{}
|
||
}
|
||
docInjectChars := opts.DocumentMaxInjectChars
|
||
if docInjectChars <= 0 {
|
||
docInjectChars = defaultDocumentInjectChars
|
||
}
|
||
if d, ok := compactor.(defaultCompactor); ok {
|
||
// 内置 compactor 的文档部件估值必须与注入预算同口径:否则注入 683K 字符的
|
||
// 附件在压缩估算里只按 64K 计,压缩永远迟一步(切片 T1 §7.2)。
|
||
d.documentInjectChars = docInjectChars
|
||
compactor = d
|
||
}
|
||
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,
|
||
documentInjectChars: docInjectChars,
|
||
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")
|
||
}
|