package runtime import ( "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/prompts" "agent-system-backend/internal/quality" core "agent-system-core" ) // LLMRunner 主对话执行器:复用 agent-native 的 OpenAI 兼容流式客户端(不自研接入层)。 // 回合循环交给 runtime.Run;这里负责模型路由、提示词、上下文组装和工具声明。 type LLMRunner struct { // Prompts 提示词库(nil = 回退画像 SystemPrompt 单段,兼容测试与骨架)。 Prompts *prompts.Service // SkillsDir 内置技能目录(/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" ) // 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 `你是会话压缩器。把此前的对话压缩为一份完整摘要,供后续会话作为唯一上下文继续工作。压缩期间不得调用任何工具,纯文本输出:先在 块内按时间顺序梳理对话(用户意图、采用的方法、关键决策、涉及的文件与数据细节、错误与修复、用户给过的反馈),再输出 块。 摘要必须包含九部分: 1. 主要请求和意图:用户要什么、为什么。 2. 关键技术概念:领域、技术栈、核心机制。 3. 文件和数据:涉及的文件/对象路径、关键代码或数据片段、谁改了什么。 4. 错误和修复:出现过的问题与解法。 5. 问题解决过程:尝试过什么、哪些有效哪些放弃。 6. 所有用户消息:逐条保留(含澄清与反馈)。 7. 待办任务:尚未完成的事项。 8. 当前状态:进行到哪一步、最新状态。 9. 下一步计划:用户或执行者提出的后续动作。` } // toolDesc 工具描述:库覆盖(tool.)优先,回落注册表文案。 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() route := ResolveChatModel(prof, eff) 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 := openLLMClient(route.Base, route.Key, route.Model) stream = client.Stream } // 知识注入:当前消息包含 @knowledge 引用时检索注入(最小实现:显式引用)。 var knowledge []core.RetrievalHit if 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) 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 msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact, ctxasm.AssembleOptions{ Surfaces: rc.Surfaces(), ThinkReplay: thinkReplayEnabled(eff, route.Model), }) tail := ctxasm.Tail{ Instructions: eff.GetString("context.userInstructions", ""), History: hist, Window: int(eff.GetNumber("context.contextWindow", 32000)), } 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"]}`)}, ) 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":{}}`), }) } 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, } return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools, refresh) } 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 消息是否原样回传 块。 // 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" } // 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 的 块里;reasoning_split=true 让思考改走独立 // reasoning_content 字段(SSE 捕获路径不变,正文流不再夹带思考)。 if strings.HasPrefix(model, "MiniMax-M") { c.ReasoningSplit = true } return c } // 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 = "你的上一段回复声称已经生成或登记了文件,但这一轮没有产生对应的工件。请二选一:用 run_python 写出文件并调用 register_artifact 登记,或如实说明这一轮没有生成文件。不要编造工件 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 剥离正文里内联的 … 推理块(部分 OpenAI 兼容 // 模型把思考混进 content 流):返回(干净正文, 思考内容)。只处理首部成对标签; // 未闭合或非首部出现按原文返回(宁可多显示也不误删正文)。 func splitThinkBlock(s string) (body, think string) { trimmed := strings.TrimLeft(s, " \t\r\n") if !strings.HasPrefix(trimmed, "") { return s, "" } rest := strings.TrimPrefix(trimmed, "") i := strings.Index(rest, "") if i < 0 { return s, "" } think = strings.TrimSpace(rest[:i]) body = strings.TrimSpace(rest[i+len(""):]) if think == "" { return body, "" } return body, think }