Files
Agent-System-Backend/internal/runtime/llm.go
T
agent-exec-transport 16f98b8e4f
governance / governance (pull_request) Successful in 21s
ci / vet-and-test (pull_request) Successful in 1m26s
agent-exec transport 4792e7917ab99a9f3fd0e21823af20c0/c578cddeabbd
2026-10-20 07:58:27 +00:00

790 lines
35 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 runtime
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"math"
"net/http"
"strings"
"time"
nruntime "agent-native/runtime"
"agent-native/runtime/gates"
agentllm "agent-native/runtime/llm"
agentmemory "agent-native/runtime/memory"
"agent-native/runtime/policy"
agenttools "agent-native/runtime/tools"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/projectcontext"
"agent-system-backend/internal/prompts"
"agent-system-backend/internal/quality"
"agent-system-backend/internal/vision"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// LLMRunner 主对话执行器:复用 agent-native 的 OpenAI 兼容流式客户端(不自研接入层)。
// 回合循环交给 runtime.Run;这里负责模型路由、提示词、上下文组装和工具声明。
type LLMRunner struct {
// Prompts 提示词库(nil = 回退画像 SystemPrompt 单段,兼容测试与骨架)。
Prompts *prompts.Service
// SkillsDir 内置技能目录(<data>/skills)。写入环境段,供模型定位 house-style。
SkillsDir string
// Stream 提供方边界注入点(nil = 按模型路由构造 agent-native 客户端)。
Stream agentllm.StreamFunc
// Now 注入时钟(nil = time.Now)。测试用它固定思考与工具耗时。
Now func() time.Time
// VerifyStream 是终轮验证器的注入点(nil = 按轻量模型构造客户端)。
VerifyStream agentllm.StreamFunc
// Trace 记录 invoke_agent、chat、execute_tool。nil 不采集。
Trace func(name string, attrs map[string]string) func()
}
func (l *LLMRunner) Name() string { return "llm" }
func (l *LLMRunner) now() time.Time {
if l.Now != nil {
return l.Now()
}
return time.Now()
}
// durationMs 把 [start, end] 换成毫秒。时钟未打点时返回 0。
func durationMs(start, end time.Time) float64 {
if start.IsZero() || end.Before(start) {
return 0
}
return float64(end.Sub(start).Microseconds()) / 1000
}
const (
toolDelegate = "delegate_task"
toolReview = "review_findings"
toolPlan = "update_plan"
toolTodos = "update_todos"
toolSquad = "squad_orchestrate"
toolRunWorkflow = "run_workflow"
toolCard = "submit_card" // 产物卡提交(ui.cardflow 开启时声明;D39 显式路由)
// 流式产卡三段式(同门控声明;D44/D45):begin 建卡、append 逐段追加、
// commit 收尾——长正文优先走此路径,submit_card 留给一次性短产物。
toolCardBegin = "card_begin"
toolCardAppend = "card_append"
toolCardCommit = "card_commit"
// confirm_choice 发起待确认选择卡(材料整理完成后选交付物;I2)。
toolConfirmChoice = "confirm_choice"
toolReadProjectEvidence = "read_project_evidence"
)
// resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目
// 与本轮命中的 task 层模块(转录权威模式下系统段只在播种时存在,后续轮的新命中
// 模块经 Reminders 注入)。
func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message, []string, []core.PromptBlock) {
userText := currentUserText(rc)
atts := rc.Attachments()
names := make([]string, len(atts))
for i, a := range atts {
names[i] = a.Meta.Filename
}
now := time.Now()
env := prompts.EnvData{
Date: now.Format("2006-01-02"),
Weekday: weekdayCN(now),
Model: model,
DeliverableStyle: deliverableStyleLine(rc.Settings()),
SkillsDir: l.SkillsDir,
}
if len(names) > 0 {
env.Attachments = strings.Join(names, "、")
}
if listed, ok := rc.(interface{ WorkspaceInputs() []string }); ok {
if paths := listed.WorkspaceInputs(); len(paths) > 0 {
env.Inputs = strings.Join(paths, "、")
}
}
prof := rc.Profile()
blocks, hits, err := l.Prompts.ResolveSystem(prof, prompts.TriggerCtx{
Text: userText, HasAttachments: len(atts) > 0,
}, env, prof.Owner)
if err != nil {
return nil, []string{"prompt(库解析失败:" + err.Error() + ")"}, nil
}
// 卡流注入(D39/D42):ui.cardflow 开启时附加 style.cardflow 模块正文。
// 不走 PromptModules(向空列表追加会翻转 pick 的"显式引用"语义),独立成块。
if rc.Settings().GetBool("ui.cardflow", false) {
if body, ok, err := l.Prompts.ModuleBody(prof.Owner, "style.cardflow"); err == nil && ok {
blocks = append(blocks, core.PromptBlock{ModuleID: "style.cardflow", Layer: core.LayerStyle, Body: body})
hits = append(hits, core.PromptHitRecord{ModuleID: "style.cardflow", Injected: true, Reason: "setting(ui.cardflow)"})
}
}
msgs := make([]agentllm.Message, 0, len(blocks))
var taskBlocks []core.PromptBlock
for _, b := range blocks {
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: b.Body})
if b.Layer == core.LayerTask {
taskBlocks = append(taskBlocks, b)
}
}
var obs []string
for _, h := range hits {
if h.Injected {
obs = append(obs, fmt.Sprintf("prompt(%s v%d · %s)", h.ModuleID, versionOf(blocks, h.ModuleID), h.Reason))
} else {
obs = append(obs, fmt.Sprintf("skill-yield(%s · %s)", h.ModuleID, h.Reason))
}
}
return msgs, obs, taskBlocks
}
// deliverableStyleLine 把设置里的默认主题、强调色和模板压成环境段的一句话。
func deliverableStyleLine(eff core.EffectiveSettings) string {
style := strings.TrimSpace(eff.GetString("deliverables.style", "modern"))
if style == "" {
style = "modern"
}
line := style
if accent := strings.TrimSpace(eff.GetString("deliverables.accentColor", "")); accent != "" {
line += ",强调色 " + accent
}
if strings.TrimSpace(eff.GetString("deliverables.templateArtifactId", "")) != "" {
line += ";已配置默认模板(见 inputs 中的 _default-template)"
}
return line
}
// compactPrompt 会话压缩摘要的系统提示:库覆盖(aux.session-compact)优先,
// 回退九段结构化摘要的内联文案(与提示词库模板同构)。
func (l *LLMRunner) compactPrompt(owner string) string {
if l.Prompts != nil {
if body, ok, err := l.Prompts.ModuleBody(owner, "aux.session-compact"); err == nil && ok && strings.TrimSpace(body) != "" {
return body
}
}
return `你是会话压缩器。把此前的对话压缩为一份完整摘要,供后续会话作为唯一上下文继续工作。压缩期间不得调用任何工具,纯文本输出:先在 <analysis> 块内按时间顺序梳理对话(用户意图、采用的方法、关键决策、涉及的文件与数据细节、错误与修复、用户给过的反馈),再输出 <summary> 块。
摘要必须包含九部分:
1. 主要请求和意图:用户要什么、为什么。
2. 关键技术概念:领域、技术栈、核心机制。
3. 文件和数据:涉及的文件/对象路径、关键代码或数据片段、谁改了什么。
4. 错误和修复:出现过的问题与解法。
5. 问题解决过程:尝试过什么、哪些有效哪些放弃。
6. 所有用户消息:逐条保留(含澄清与反馈)。
7. 待办任务:尚未完成的事项。
8. 当前状态:进行到哪一步、最新状态。
9. 下一步计划:用户或执行者提出的后续动作。`
}
// toolDesc 工具描述:库覆盖(tool.<name>)优先,回落注册表文案。
func (l *LLMRunner) toolDesc(owner, name, fallback string) string {
if l.Prompts != nil {
if body, ok, err := l.Prompts.ToolPrompt(owner, name); err == nil && ok && strings.TrimSpace(body) != "" {
return body
}
}
return fallback
}
// artifactFacadeCard 构造工具大结果的门面卡(D47):kind=result、status=done,
// body = 摘要 markdown + 来源 kv + doc-ref(正文权威在工件,卡只做门面与锚)。
func artifactFacadeCard(tool string, art core.Artifact, summary string) core.ActivityCard {
md := strings.TrimSpace(summary)
if md == "" {
md = "工具结果超限,完整内容已存为工件"
}
return core.ActivityCard{
Kind: core.CardKindResult, Title: "工具大结果 · " + art.Name,
Status: core.CardDone, Tone: "info",
Body: []core.UINode{
{Type: "markdown", Markdown: md},
{Type: "kv", KV: [][2]string{{"来源工具", tool}, {"工件", art.Name}}},
{Type: "doc-ref", DocID: core.ID(art.ID), Title: art.Name},
},
}
}
func versionOf(blocks []core.PromptBlock, id string) int {
for _, b := range blocks {
if b.ModuleID == id {
return b.Version
}
}
return 0
}
func weekdayCN(t time.Time) string {
switch t.Weekday() {
case time.Sunday:
return "周日"
case time.Monday:
return "周一"
case time.Tuesday:
return "周二"
case time.Wednesday:
return "周三"
case time.Thursday:
return "周四"
case time.Friday:
return "周五"
default:
return "周六"
}
}
func (l *LLMRunner) Execute(rc RunCtx) error {
eff := rc.Settings()
prof := rc.Profile()
projectMode := projectContextEnabled(rc)
hasImages := vision.HasVisualInput(rc.Attachments(), currentUserText(rc))
route := ResolveVisionModel(prof, eff, hasImages)
if route.Model == "" || route.Base == "" {
return fmt.Errorf("llm: 主模型未配置(当前解析:model=%q base=%q;检查 settings.models.chat 与画像 ModelRoute;演示模式可开 runtime.mock)", route.Model, route.Base)
}
// 终轮评分的 lint 子进程与 run_python 同一技能根:不注入的话 housestyle
// 在后端进程里 import 失败,lint 静默变空结果(评分门禁半边失效)。
// 装配放这里而不是 app 组合根:runtime 已依赖 quality(qualityScorer),
// app→quality 会被架构棘轮拦截;幂等,进程级只需设一次。
if l.SkillsDir != "" {
quality.SetLintEnvRoot(l.SkillsDir)
}
stream := l.Stream
if stream == nil {
// 重试经库默认(WP6:runtime.llmRetries → Config.RetryAttempts 映射),
// 此处不再显式包装(防双重)。
client := openRouteClient(route, eff)
stream = client.Stream
}
stream = annotateKeyFailure(stream, route)
// 知识注入:当前消息包含 @knowledge 引用时检索注入(最小实现:显式引用)。
var knowledge []core.RetrievalHit
if !projectMode && strings.Contains(currentUserText(rc), "@knowledge") {
q := strings.TrimSpace(strings.ReplaceAll(currentUserText(rc), "@knowledge", ""))
if hits, err := rc.Retrieve(core.RetrievalQuery{Text: q}); err == nil {
knowledge = hits
}
}
// 组装初始上下文。
hist := rc.History()
currentUser, _ := lastAddressed(hist)
if projectMode {
// Historical assistant/tool text has no durable resource provenance. A
// later duty revocation cannot safely reauthorize it, so project work
// starts from the current user turn and a fresh provider bundle.
hist = []core.Message{currentUser}
}
var sys []agentllm.Message
var promptObs []string
var taskBlocks []core.PromptBlock
if l.Prompts != nil {
sys, promptObs, taskBlocks = l.resolveSystem(rc, route.Model)
}
// 工件正文回填(D46):artifact_ref 与 card 部件同样回填,下一轮不失忆。
resolveArtifact := func(id core.ID) (core.Artifact, []byte, bool) { return rc.Artifact(id) }
// 记忆注入迁移到记忆端口(N3 WP5):由循环经 Config.Memory 按请求注入,
// 组装侧不再注入(防双份;注入文案同一形态)。
_ = ctxasm.InjectMemory
asmProf := prof
var surfaces []core.SurfaceSnapshot
if projectMode {
asmProf.SystemPrompt = "" // profile text has no resource provenance
} else {
surfaces = rc.Surfaces()
}
msgs, asmReport := ctxasm.AssembleInput(asmProf, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact, ctxasm.AssembleOptions{
Surfaces: surfaces,
ThinkReplay: thinkReplayEnabled(eff, route.Model),
})
vision.AttachLinkedImages(rc.Context(), msgs)
tail := ctxasm.Tail{
History: hist,
Window: int(eff.GetNumber("context.contextWindow", 32000)),
}
if !projectMode {
tail.Instructions = eff.GetString("context.userInstructions", "")
if note, ok := rc.(interface{ SessionNote() string }); ok {
tail.Note = note.SessionNote()
}
if src, ok := rc.(interface{ Injections() []ContextInjection }); ok {
for _, e := range src.Injections() {
tail.Entries = append(tail.Entries, ctxasm.Injection{
Keys: e.Keys, Secondary: e.Secondary, Logic: e.Logic, Content: e.Content,
Constant: e.Constant, ScanDepth: e.ScanDepth, BudgetChars: e.BudgetChars,
})
}
}
}
msgs, asmReport = ctxasm.FinishAssembly(msgs, asmReport, tail)
if asmReport != nil {
sections := make([]core.ContextSection, len(asmReport.Sections))
for i, sec := range asmReport.Sections {
sections[i] = core.ContextSection{Name: sec.Name, EstTokens: sec.EstTokens}
}
rc.SetContextObservation(core.ContextObservation{
Items: asmReport.Items, Truncations: asmReport.Truncations, EstChars: asmReport.EstChars,
Sections: sections, PrefixHash: asmReport.PrefixHash,
})
}
// 声明工具:编排型内置 + 注册表中画像允许的工具;描述文案库覆盖优先。
// runtime.entry=squad 时对话本身就是小队入口,不再提供 squad_orchestrate。
decls := []agentllm.ToolDecl{}
if squadToolEnabled(eff) {
decls = append(decls, agentllm.ToolDecl{Name: toolSquad, Description: l.toolDesc(prof.Owner, toolSquad, "用小队编排引擎执行多步骤目标:规则规划器拆解为 梳理→专项→成文→终审 步骤并逐步执行(每步一个子代理),挂起决策会以提问卡片征求用户"), Parameters: json.RawMessage(`{"type":"object","properties":{"goal":{"type":"string","description":"要完成的整体目标描述"}},"required":["goal"]}`)})
}
decls = append(decls,
agentllm.ToolDecl{Name: toolDelegate, Description: l.toolDesc(prof.Owner, toolDelegate, "派生一个子代理执行独立子任务(如检索、撰写),返回其结果摘要。模型和工具由子代理 profile 决定,调用方不能指定模型。background 为真时立即返回——这是重任务的慢通道:先在当前回复里给用户即时反馈(相关性判断/概要/计划),深度工作放后台;系统会在收件箱建进度卡并在完成后回投结果、自动发起一轮汇报。预计要跑几分钟的任务(整篇文档深度解析、多文件批量处理)优先用此模式,不要让用户干等"), Parameters: json.RawMessage(`{"type":"object","properties":{"agent":{"type":"string","enum":["general","explore","research","plan","verify","writer"],"description":"子代理 profile,缺省 general"},"name":{"type":"string","description":"显示名"},"task":{"type":"string","description":"子任务描述"},"hint":{"type":"string","description":"执行提示"},"background":{"type":"boolean","description":"为真时在后台运行(慢通道),当前回合不等待结果;收件箱有进度卡,完成后自动汇报"}},"required":["name","task"]}`)},
agentllm.ToolDecl{Name: toolReview, Description: l.toolDesc(prof.Owner, toolReview, "对一段材料做评审:多个审查子代理并行找问题,再逐条独立验证,只保留验证通过的问题"), Parameters: json.RawMessage(`{"type":"object","properties":{"target":{"type":"string","description":"要评审的文本或工件"},"focus":{"type":"string","description":"评审重点"},"reviewers":{"type":"integer","minimum":1,"maximum":3,"description":"并行审查人数,1 到 3,缺省 1"}},"required":["target"]}`)},
agentllm.ToolDecl{Name: toolPlan, Description: l.toolDesc(prof.Owner, toolPlan, "创建或更新当前会话的计划(步骤列表)"), Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"},"steps":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done","skipped"]}},"required":["id","text","status"]}}},"required":["title","steps"]}`)},
agentllm.ToolDecl{Name: toolTodos, Description: l.toolDesc(prof.Owner, toolTodos, "更新当前会话的待办清单"), Parameters: json.RawMessage(`{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done"]}},"required":["id","text","status"]}}},"required":["items"]}`)},
agentllm.ToolDecl{Name: toolConfirmChoice, Description: l.toolDesc(prof.Owner, toolConfirmChoice, "向用户发起待确认选择卡并等待答复:3-4 个选项(各附一句依据与影响)+ 可选自由输入。材料整理完成后选正式交付物、或任何需要用户拍板的分叉都用它,不要在文本里罗列选项后干等"), Parameters: json.RawMessage(`{"type":"object","properties":{"question":{"type":"string","description":"要用户决定的问题,一句话"},"options":{"type":"array","minItems":2,"maxItems":4,"items":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string","description":"选项名,如:会议摘要"},"description":{"type":"string","description":"一句依据(来自材料或上下文)"},"impact":{"type":"string","description":"选择后的产出与耗时影响"},"recommended":{"type":"boolean","description":"最贴合的一项设 true"}},"required":["id","label","description","impact"]}},"free_input":{"type":"boolean","description":"允许自由输入其他要求,默认 true"},"timeout_sec":{"type":"integer","description":"等待上限秒数,0=不限"}},"required":["question","options"]}`)},
)
if eff.GetString("runtime.entry", "direct") != "squad" {
decls = append(decls, agentllm.ToolDecl{
Name: toolRunWorkflow,
Description: l.toolDesc(prof.Owner, toolRunWorkflow, "运行一个工作流。workflow 运行已注册的定义并带上 inputs;goal 让模型先生成步骤再请用户确认;script 在开关打开后运行一段脚本。三者只能填一个"),
Parameters: json.RawMessage(`{"type":"object","properties":{"workflow":{"type":"string","description":"已注册工作流 id"},"inputs":{"type":"object","additionalProperties":{"type":"string"},"description":"槽位初值"},"goal":{"type":"string","description":"用模型生成步骤"},"script":{"type":"string","description":"要运行的脚本"}},"additionalProperties":false}`),
})
}
cardflow := eff.GetBool("ui.cardflow", false)
if cardflow {
decls = append(decls, agentllm.ToolDecl{
Name: toolCard,
Description: l.toolDesc(prof.Owner, toolCard, "提交产物型长内容(报告/纪要/方案/对比/文档草稿)为右侧收件箱卡片:卡片承载全文,对话中只保留结论摘要。解释型回答不要用此工具"),
Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string","description":"产物标题"},"summary":{"type":"string","description":"一两句结论摘要(显示在卡片与对话锚点上)"},"body":{"type":"string","description":"完整 Markdown 正文"},"scope":{"type":"string","enum":["conversation","project"],"description":"卡片归属:conversation=当前对话(默认)、project=项目收件箱(跨会话可见)"}},"required":["title","summary","body"]}`),
})
decls = append(decls, agentllm.ToolDecl{
Name: toolCardBegin,
Description: l.toolDesc(prof.Owner, toolCardBegin, "开始流式生成右侧收件箱产物卡(长正文推荐):先给出标题与可选摘要,随后用 card_append 逐段追加正文,写完用 card_commit 收尾"),
Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string","description":"产物标题"},"summary":{"type":"string","description":"一两句结论摘要(可选)"},"scope":{"type":"string","enum":["conversation","project"],"description":"卡片归属:conversation=当前对话(默认)、project=项目收件箱(跨会话可见)"}},"required":["title"]}`),
})
decls = append(decls, agentllm.ToolDecl{
Name: toolCardAppend,
Description: l.toolDesc(prof.Owner, toolCardAppend, "向当前流式产物卡追加一段 Markdown 正文(只追加,不改写已提交内容)"),
Parameters: json.RawMessage(`{"type":"object","properties":{"text":{"type":"string","description":"本段 Markdown 文本"}},"required":["text"]}`),
})
decls = append(decls, agentllm.ToolDecl{
Name: toolCardCommit,
Description: l.toolDesc(prof.Owner, toolCardCommit, "收尾当前流式产物卡:卡片转为完成态并锚定到本条对话"),
Parameters: json.RawMessage(`{"type":"object","properties":{}}`),
})
}
if reader, ok := rc.(interface {
ProjectContextEnabled() bool
ProjectEvidence(context.Context, string) (extension.ProjectEvidence, error)
}); ok && reader.ProjectContextEnabled() {
decls = append(decls, agentllm.ToolDecl{Name: toolReadProjectEvidence,
Description: "Read one currently authorized project evidence reference offered in this request. Access is checked again at read time.",
Parameters: json.RawMessage(`{"type":"object","properties":{"reference":{"type":"string"}},"required":["reference"]}`)})
}
for _, t := range toolList(rc) {
if prof.Tools.ToolAllowed(t.Name) {
td := t
td.Description = l.toolDesc(prof.Owner, td.Name, td.Description)
decls = append(decls, agentllm.ToolDecl{Name: td.Name, Description: td.Description, Parameters: td.Parameters})
}
}
decls = filterPlanDecls(decls, permissionModeOf(rc))
decls = applyToolSelection(decls, eff.GetString("runtime.toolSelection", ""))
maxTurns, maxTools := loopCaps(prof.Budget)
streamOpts := agentllm.StreamOptions{
MaxTokens: outputTokenCap(prof.Budget, eff, "models.chat.maxOutputTokens"),
MessageShape: eff.GetString("context.messageShape", "merge"),
}
// 转录权威模式的逐轮刷新(经 Reminders 注入,不落转录)。
refresh := turnRefresh{
Notes: ctxasm.TailNotes(tail),
EnvUpdate: envUpdateLine(rc, route.Model),
TaskBlocks: taskBlocks,
}
if source, ok := rc.(interface {
ProjectContext(context.Context, string, int) (extension.ProjectContextBundle, bool, error)
}); ok {
refresh.ProjectContext = func() (string, *extension.ProjectContextManifest, error) {
const budget = 12000
bundle, active, err := source.ProjectContext(rc.Context(), currentUserText(rc), budget)
if err != nil || !active {
return "", nil, err
}
body, m, err := projectcontext.Assemble(bundle, budget)
return body, &m, err
}
}
return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools, refresh)
}
func projectContextEnabled(rc RunCtx) bool {
source, ok := rc.(interface{ ProjectContextEnabled() bool })
return ok && source.ProjectContextEnabled()
}
func permissionModeOf(rc RunCtx) string {
if m, ok := rc.(interface{ PermissionMode() string }); ok {
return m.PermissionMode()
}
return ""
}
func filterPlanDecls(decls []agentllm.ToolDecl, mode string) []agentllm.ToolDecl {
if policy.Normalize(mode) != policy.ModePlan {
return decls
}
out := make([]agentllm.ToolDecl, 0, len(decls))
for _, d := range decls {
if policy.Visible(mode, d.Name, policy.TraitsFor(d.Name).ReadOnly) {
out = append(out, d)
}
}
return out
}
func applyToolSelection(decls []agentllm.ToolDecl, spec string) []agentllm.ToolDecl {
spec = strings.TrimSpace(spec)
if spec == "" {
return decls
}
byName := map[string]agentllm.ToolDecl{}
names := make([]string, 0, len(decls))
for _, d := range decls {
byName[d.Name] = d
names = append(names, d.Name)
}
var next []agentllm.ToolDecl
for _, name := range agenttools.ApplySelection(names, spec) {
if d, ok := byName[name]; ok {
next = append(next, d)
}
}
return next
}
// contextWindow 把模型真实窗口(token)换算成内核清理阈值用的字符数。
// 内核 EstimateChars 按 rune 计数;按 ctxasm 同口径(UTF-8 字节/3.35 ≈ token,
// 中文约一字一 token)反推 chars ≈ tokens×3.35。此前 32000 字符的硬默认会让
// 带长材料的任务在第一个请求就触发清理,被清内容只留一句「见会话记录」。
func contextWindow(eff core.EffectiveSettings) (window, reserve, buffer int) {
tokens := eff.GetNumber("models.chat.contextWindow", 128000)
if tokens < 4096 {
tokens = 4096
}
window = int(tokens * 3.35)
reserve = int(eff.GetNumber("models.chat.outputReserve", 16384) * 3.35)
buffer = int(eff.GetNumber("models.chat.contextBuffer", 4096) * 3.35)
return
}
// thinkReplayEnabled 决定历史 assistant 消息是否原样回传 <think> 块。
// MiniMax 官方要求保留(去掉会掉性能);auto 按模型名识别。
func thinkReplayEnabled(eff core.EffectiveSettings, model string) bool {
switch eff.GetString("context.thinkReplay", "auto") {
case "on":
return true
case "off":
return false
}
return strings.Contains(strings.ToLower(model), "minimax")
}
// loopCaps 把画像预算换成内核参数。轮次或工具数为 0 / 未设置表示不限制;
// 内核对 MaxTurns<=0 会回落到 12,所以不限制时传 MaxInt32。MaxToolCalls 的 0 才是不限制。
func loopCaps(budget core.RunBudget) (maxTurns, maxTools int) {
maxTurns = math.MaxInt32
if budget.MaxTurns != nil && *budget.MaxTurns > 0 {
maxTurns = *budget.MaxTurns
}
if budget.MaxToolCalls != nil && *budget.MaxToolCalls > 0 {
maxTools = *budget.MaxToolCalls
}
return maxTurns, maxTools
}
func toolBudget(maxTools int) *nruntime.Budget {
if maxTools <= 0 {
return nil
}
return &nruntime.Budget{MaxToolCalls: maxTools}
}
// outputTokenCap 优先用画像 budget.maxTokens,否则用设置项。两者都是 0 表示不下发,由服务端决定。
func outputTokenCap(budget core.RunBudget, eff core.EffectiveSettings, key string) int {
if budget.MaxTokens != nil && *budget.MaxTokens > 0 {
return *budget.MaxTokens
}
n := int(eff.GetNumber(key, 0))
if n > 0 {
return n
}
return 0
}
// memoryStore 是记忆端口的最小面(agent-native runtime/memory.Store 的本地别名;
// 组合根经 runCtx 注入,runmgr 的 storeMemory 实现之)。
type memoryStore = agentmemory.Store
// retryAttemptsOf 把 runtime.llmRetries 的设置语义(0=不重试,N=N 次)
// 映射到库 Config.RetryAttempts(0=默认 2,负值=关,正数=次数)。
func retryAttemptsOf(eff core.EffectiveSettings) int {
n := int(eff.GetNumber("runtime.llmRetries", 2))
if n <= 0 {
return -1
}
return n
}
// agentNameOf 是 OTel gen_ai.agent.name 的取值(画像 ID,空回退 agent-system)。
func agentNameOf(prof core.AgentProfile) string {
if strings.TrimSpace(prof.ID) != "" {
return prof.ID
}
return "agent-system"
}
// annotateKeyFailure 在 401 的错误文本后附上密钥来源和脱敏指纹,不附明文。
func annotateKeyFailure(stream agentllm.StreamFunc, route ResolvedModel) agentllm.StreamFunc {
if stream == nil {
return nil
}
return func(ctx context.Context, model string, messages []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
out := make(chan agentllm.Event)
go func() {
defer close(out)
for ev := range stream(ctx, model, messages, tools, opts) {
if ev.HTTPStatus == http.StatusUnauthorized {
ev = withKeyProvenance(ev, route)
}
select {
case <-ctx.Done():
return
case out <- ev:
}
}
}()
return out
}
}
func withKeyProvenance(ev agentllm.Event, route ResolvedModel) agentllm.Event {
note := keyProvenance(route)
ev.ErrorMessage += note
switch err := ev.Err.(type) {
case *agentllm.StreamError:
err.Message += note
case nil:
ev.Err = fmt.Errorf("%s", strings.TrimSpace(note))
default:
ev.Err = fmt.Errorf("%s%s", err.Error(), note)
}
return ev
}
func keyProvenance(route ResolvedModel) string {
sum := sha256.Sum256([]byte(route.Key))
source := route.KeySource
if source == "" {
source = "settings"
}
return fmt.Sprintf(" (key source=%s fp=%x)", source, sum[:4])
}
// openLLMClient 不设 HTTP 客户端超时。单次生成可能很长,停止按钮通过 run 的 context 取消。
func openLLMClient(base, key, model string) *agentllm.Client {
c := &agentllm.Client{BaseURL: base, APIKey: key, HTTPClient: &http.Client{}}
// MiniMax 系(interleaved thinking)的 OpenAI 兼容接口默认把思考混在
// content 的 <think> 块里;reasoning_split=true 让思考改走独立
// reasoning_content 字段(SSE 捕获路径不变,正文流不再夹带思考)。
if strings.HasPrefix(model, "MiniMax-M") {
c.ReasoningSplit = true
}
return c
}
// openRouteClient 按一次解析出的路由构造客户端,并为该路由独立判定 RouteContract。
func openRouteClient(route ResolvedModel, eff core.EffectiveSettings) *agentllm.Client {
c := openLLMClient(route.Base, route.Key, route.Model)
c.RouteContract = routeContractFor(eff, route)
return c
}
// routeContractFor 只看本客户端实际解析出的 model 与 base,不看 route.Kind,
// 也不读画像:三项系统设置任一为空、模型名不逐字相等、规范化后的端点与配置
// 不逐字相等(配置本身须已是规范形态)或身份摘要不符,都返回 nil(关闭)。
func routeContractFor(eff core.EffectiveSettings, route ResolvedModel) *agentllm.RouteContractEOF {
model := eff.GetString("models.routeContractEOF.model", "")
base := eff.GetString("models.routeContractEOF.baseUrl", "")
digest := eff.GetString("models.routeContractEOF.identitySha256", "")
if model == "" || base == "" || digest == "" || route.Model != model {
return nil
}
if normalized, ok := agentllm.NormalizeRouteBaseURL(route.Base); !ok || normalized != base {
return nil
}
if agentllm.RouteContractIdentity(model, base) != digest {
return nil
}
return &agentllm.RouteContractEOF{Model: model, BaseURL: base, IdentitySHA256: digest}
}
// RouteContractFor 供评测等组合根按与生产完全相同的规则判定 RouteContract。
func RouteContractFor(eff core.EffectiveSettings, route ResolvedModel) *agentllm.RouteContractEOF {
return routeContractFor(eff, route)
}
// SubLLMRunner 子代理执行器:按内置 profile 跑一轮带工具的内核循环。
type SubLLMRunner struct {
// Prompts 提示词库(nil = 使用内联回退文案与只读回退 spec)。
Prompts *prompts.Service
// Stream 提供方边界注入点(nil = 按 profile 的模型档构造客户端)。
Stream agentllm.StreamFunc
// Trace 记录子代理循环的 span。nil 不采集。
Trace func(name string, attrs map[string]string) func()
}
func currentUserText(rc RunCtx) string {
msg, ok := lastAddressed(rc.History())
if !ok {
return ""
}
for _, p := range msg.Parts {
if p.Type == core.PartText {
return p.Text
}
}
return ""
}
// lastAddressed 取最后一条用户消息或系统通知,避免通知回合重答更早的问题。
func lastAddressed(hist []core.Message) (core.Message, bool) {
for i := len(hist) - 1; i >= 0; i-- {
if hist[i].Role == core.RoleUser || hist[i].Role == core.RoleSystem {
return hist[i], true
}
}
return core.Message{}, false
}
func toolList(rc RunCtx) []core.ToolDesc { return rc.Tools() }
// squadToolEnabled 在小队已经是对话入口时关掉 squad_orchestrate,避免再套一层编排。
func squadToolEnabled(eff core.EffectiveSettings) bool {
if eff.GetString("runtime.entry", "direct") == "squad" {
return false
}
return eff.GetBool("runtime.squad", true)
}
func orDefault(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
const deliverableGuardNote = "你的上一段回复声称已经生成或登记了文件,但这一轮没有产生对应的工件。普通 Word 请调用 render_document;Excel/PPT/PDF/图片请用 run_python 生成并调用 register_artifact;否则如实说明没有生成。项目或受控 Word 必须交给 RDM。不要编造工件 ID,也不要在回复里写 art_ 开头的编号。正文里的「调用工具」「工具结果」「产出工件」字样不会执行任何东西,调用工具必须用函数调用。"
const discardedClaimNote = "(上一段回复声称已交付,但本轮没有任何工具调用,已作废)"
const deliverableFallbackNotice = "本轮未实际生成文件(模型声称已生成,但没有调用工具)。请重试,或换一种说法再试一次。"
// claimsFalseDeliverable 保留给小队入口:命中即判失败,不走直连路径的纠正轮次。
func claimsFalseDeliverable(body string, produced []*core.ArtifactRef, rc RunCtx) bool {
block, _ := deliverableGateOf(body, produced, rc)
return block
}
// deliverableGateOf 把产品工件存取投影成库端口后调用下沉的 ClaimGate(N1 WP5)。
func deliverableGateOf(body string, produced []*core.ArtifactRef, rc RunCtx) (bool, string) {
return gates.ClaimGate(body, artifactRefsOf(produced), artifactLookupOf(rc))
}
// artifactRefsOf 把 core.ArtifactRef 投影为库的最小形态。
func artifactRefsOf(refs []*core.ArtifactRef) []gates.ArtifactRef {
out := make([]gates.ArtifactRef, 0, len(refs))
for _, ref := range refs {
if ref == nil || ref.ArtifactID == "" {
continue
}
out = append(out, gates.ArtifactRef{ID: ref.ArtifactID, Name: ref.Name, Kind: ref.Kind})
}
return out
}
// artifactLookupOf 把 rc.Artifact 投影为库的工件存取端口。
func artifactLookupOf(rc RunCtx) gates.ArtifactLookup {
return func(id string) (string, []byte, bool) {
art, content, ok := rc.Artifact(id)
if !ok {
return "", nil, false
}
return art.Name, content, true
}
}
// hardFactScorer 把 quality.ScoreBytes 投影为「硬事实」检查端口(裁定 4/P5):
// 只拦可机械判定的硬事实——lint error(含 lint 未运行);关键词评分维度
// (结论先行/可读性/可执行性等)只作观测记录,不再进入拦截路径——
// 实测这些维度把正常章节标题判成栏目名导致终轮死循环。
func hardFactScorer() gates.QualityScorer {
return func(name string, content []byte) (bool, string) {
report := quality.HardFacts(name, content)
if report.Passed {
return true, ""
}
return false, strings.Join(report.Violations, ";")
}
}
func summarize(out string, err error) string {
if err != nil {
return truncateRun(err.Error(), 160)
}
return truncateRun(out, 160)
}
func statusOf(err error) string {
if err != nil {
return "error"
}
return "ok"
}
func truncateRun(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
// tailRunes 取尾部,给验证器看同一次命令的最后结果,而不是被截掉的开头。
func tailRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return "…" + string(r[len(r)-n:])
}
// splitThinkBlock 剥离正文里内联的 <think>…</think> 推理块(部分 OpenAI 兼容
// 模型把思考混进 content 流):返回(干净正文, 思考内容)。只处理首部成对标签;
// 未闭合或非首部出现按原文返回(宁可多显示也不误删正文)。
func splitThinkBlock(s string) (body, think string) {
trimmed := strings.TrimLeft(s, " \t\r\n")
if !strings.HasPrefix(trimmed, "<think>") {
return s, ""
}
rest := strings.TrimPrefix(trimmed, "<think>")
i := strings.Index(rest, "</think>")
if i < 0 {
return s, ""
}
think = strings.TrimSpace(rest[:i])
body = strings.TrimSpace(rest[i+len("</think>"):])
if think == "" {
return body, ""
}
return body, think
}