Files
Cyrene_v2/internal/runtime/loop/loop.go
T
AskaEth 6475eb875d feat(skills): 渐进披露接线(L2)—— 清单投影 + 命中才展开 + 工具面只收不放
- 清单只给 name+description(+when-to-use),单条 ≤1536 字符 + 全局条数上限,超限如实标 truncated
- 命中才把正文作为一条消息注入:跨回合保留、按内容指纹缓存不重复读盘、注入 ≤16 KiB 标 .truncated
- invocation 三态与清单逐格对齐(model-only 清单不含 description;user-only 清单有但模型不可调)
- 激活轮的工具面 = allowed-tools ∩ 安全闸 ∩ 注册表 再减 disallowed-tools(owner 也看不见未允许的工具)
- 激活状态进程内按会话(不落库);注入量已界化(压缩器估值未含,超我文件面)

裁定(DECISIONS #3/#4/#5):删掉 paths 启发式(只认显式挂载);多技能并集;收缩作用域=激活的那个回合。
2026-10-06 14:12:24 +08:00

233 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
// skillFace 是 SK-5 的技能面(清单投影 + 命中才展开的正文 + 技能轮次的工具面
// 收缩,见 assemble_skill.go)。为 nil(SetSkillFace 未调用)时模型面与接线前
// 逐字节一致。它由装配期一次性挂上,之后只读;每会话的状态在 SkillFace 内部
// 按会话分桶并自带锁,因此循环的并发 Run 契约不变。
skillFace *SkillFace
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")
}