ci / vet-and-test (pull_request) Successful in 40s
Why: N1 WP5 host side - directRun.stop and the checklist reminders now call the library gates package behind product ports, and the nested native pin moves to the merged WP5 head. Evidence: go vet clean; go test ./... 29 packages ok, including the characterization suite unmodified and the new wp5_ab_test.go (verify-cap continuation counts and gate-reason text parity). Impact: llm.go drops the local regex family and deliverableGate; verify.go drops checkDeliverables/parseVerdict/stripFence and the local caps; workflow.go switches to gates.Verdict; reminders run through gates.ProgressReminder. Behavior is pinned identical by the existing suite plus the A/B tests. Squad reminders stay out of this increment (adapter lacks per-member tool-turn signals - recorded as WP6b). Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP5/WP6
559 lines
23 KiB
Go
559 lines
23 KiB
Go
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"
|
||
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)
|
||
}
|
||
|
||
stream := l.Stream
|
||
if stream == nil {
|
||
client := openLLMClient(route.Base, route.Key)
|
||
retries := int(eff.GetNumber("runtime.llmRetries", 2))
|
||
stream = agentllm.WithRetry(client.Stream, retries, nil)
|
||
}
|
||
|
||
// 知识注入:当前消息包含 @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) }
|
||
if ms, ok := rc.(interface{ Memories() []core.MemoryEntry }); ok {
|
||
sys = ctxasm.InjectMemory(sys, ms.Memories())
|
||
}
|
||
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
|
||
}
|
||
|
||
// 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
|
||
}
|