Files
Cyrene_v2/internal/runtime/loop/loop.go
T
AskaEth a8851dc0a2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(runtime): 文档摄取①(抽取器 + sidecar + 装配期 resolveDocuments)(design/24 S3-A)
- 抽取器零新依赖纯函数: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 条红,含「文档部件仍抵达下游」)。
2026-10-04 17:22:05 +08:00

216 lines
8.3 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
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")
}