Files
Agent-System-Backend/internal/runtime/llm.go
T
weichen d88dcb999f Wire the lint skills root from the runner instead of the app root
Why: lint-boundaries rejects new app->quality edges (architecture ratchet);
runtime already depends on quality for the scorer and holds SkillsDir, so the
injection belongs there. Same behavior, allowed dependency direction.

Evidence: SELF - go vet + go test ./internal/runtime ./internal/app pass;
lint-boundaries edge list unchanged.

Impact: quality.SetLintEnvRoot now called from LLMRunner.Execute when
SkillsDir is configured (idempotent, process-wide).
2026-10-09 12:55:54 +00:00

590 lines
25 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 (
"encoding/json"
"fmt"
"math"
"net/http"
"strings"
"time"
nruntime "agent-native/runtime"
"agent-native/runtime/gates"
agentllm "agent-native/runtime/llm"
"agent-native/runtime/policy"
agenttools "agent-native/runtime/tools"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/prompts"
"agent-system-backend/internal/quality"
agentmemory "agent-native/runtime/memory"
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"
)
// resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目。
func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message, []string) {
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() + ")"}
}
// 卡流注入(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 obs []string
for _, b := range blocks {
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: b.Body})
}
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
}
// 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
}
// 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()
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)
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
if l.Prompts != nil {
sys, promptObs = 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)
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"),
}
return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools)
}
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
}
func contextWindow(eff core.EffectiveSettings) (window, reserve, buffer int) {
window = int(eff.GetNumber("models.chat.contextWindow", 32000))
reserve = int(eff.GetNumber("models.chat.outputReserve", 4096))
buffer = int(eff.GetNumber("models.chat.contextBuffer", 1024))
return
}
// 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 string) *agentllm.Client {
return &agentllm.Client{BaseURL: base, APIKey: key, HTTPClient: &http.Client{}}
}
// 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
}
}
// qualityScorer 把 quality.ScoreBytes 投影为库的评分端口。
func qualityScorer() gates.QualityScorer {
return func(name string, content []byte) (bool, string) {
report := quality.ScoreBytes(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
}