790 lines
35 KiB
Go
790 lines
35 KiB
Go
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
|
||
}
|