Files
Agent-System-Backend/internal/runtime/llm.go
T
weichenandCursor 6533f0fa88 Add professional standards for every agent deliverable
Why: Generated files kept Word's default theme, US Letter pages,
hand-typed bullets and empty sections. Recipes only showed library
calls, the prompt had no craft standard, and seeded skills could not
be upgraded once written.

Evidence: go test ./... passed. The house-style suite covers three
themes across docx, pptx and xlsx, and rejects a document that repeats
the diagnosed layout failures. lint on the sample minutes exits 1.

Impact: Every run gets a resident quality standard. Office files use
house-style (switchable themes, templates, imitation) and must pass
lint. Skill seeding upgrades untouched builtins and keeps user edits.
Uploads land in inputs/, and the default style is a setting.

Refs: feature/agent-professional-deliverables
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 08:16:58 +00:00

732 lines
30 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"
"regexp"
"strings"
"time"
agentllm "agent-native/runtime/llm"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/prompts"
"agent-system-backend/internal/squadadapter"
core "agent-system-core"
)
// LLMRunner 主对话执行器:复用 agent-native 的 OpenAI 兼容流式客户端(不自研接入层)。
// 多轮循环:流式 → 工具调用(画像策略)→ 回灌结果 → 直至无工具调用或预算耗尽。
// 内置三个编排型工具(不经注册表):delegate_task(子代理)、update_plan、update_todos。
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
}
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"
toolPlan = "update_plan"
toolTodos = "update_todos"
toolSquad = "squad_orchestrate"
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 := &agentllm.Client{BaseURL: route.Base, APIKey: 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()
var currentUser core.Message
for i := len(hist) - 1; i >= 0; i-- {
if hist[i].Role == core.RoleUser {
currentUser = hist[i]
break
}
}
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) }
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact)
if asmReport != nil {
rc.SetContextObservation(core.ContextObservation{
Items: asmReport.Items, Truncations: asmReport.Truncations, EstChars: asmReport.EstChars,
})
}
// 声明工具:编排型内置 + 注册表中画像允许的工具;描述文案库覆盖优先。
decls := []agentllm.ToolDecl{
{Name: toolSquad, Description: l.toolDesc(prof.Owner, toolSquad, "用小队编排引擎执行多步骤目标:规则规划器拆解为 梳理→专项→成文→终审 步骤并逐步执行(每步一个子代理),挂起决策会以提问卡片征求用户"), Parameters: json.RawMessage(`{"type":"object","properties":{"goal":{"type":"string","description":"要完成的整体目标描述"}},"required":["goal"]}`)},
{Name: toolDelegate, Description: l.toolDesc(prof.Owner, toolDelegate, "派生一个子代理执行独立子任务(如检索、撰写),返回其结果摘要"), Parameters: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string","description":"子代理角色名"},"task":{"type":"string","description":"子任务描述"},"hint":{"type":"string","description":"执行提示"}},"required":["name","task"]}`)},
{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"]}`)},
{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"]}`)},
}
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":{}}`),
})
}
var toolDescs []core.ToolDesc
for _, t := range toolList(rc) {
if prof.Tools.ToolAllowed(t.Name) {
td := t
td.Description = l.toolDesc(prof.Owner, td.Name, td.Description)
toolDescs = append(toolDescs, td)
decls = append(decls, agentllm.ToolDecl{Name: td.Name, Description: td.Description, Parameters: td.Parameters})
}
}
var finalParts []core.Part
explicitCard := false // 本 run 已显式提交产物卡(submit_card/card_commit;门面卡避让,D47)
usageTotal := core.Usage{}
maxTurns := 16
if prof.Budget.MaxTurns != nil {
maxTurns = *prof.Budget.MaxTurns
}
maxTools := 40
if prof.Budget.MaxToolCalls != nil {
maxTools = *prof.Budget.MaxToolCalls
}
// 输出预算:画像 budget.maxTokens 配置时下发(0 = 走服务端默认,不限制输出长度)。
streamOpts := agentllm.StreamOptions{}
if prof.Budget.MaxTokens != nil && *prof.Budget.MaxTokens > 0 {
streamOpts.MaxTokens = *prof.Budget.MaxTokens
}
toolCallsMade := 0
thinkingIdx, textIdx := 0, 0
deliverableGuardUsed := false
// 交付物引用(对话内直接给出):工具轮产生的工件不进过程折叠区,
// 收集后作为内容部件钉在消息尾部(最终正文之后)——回复完给文件卡。
var deliverables []*core.ArtifactRef
for turn := 0; turn < maxTurns; turn++ {
select {
case <-rc.Context().Done():
return rc.Context().Err()
default:
}
var turnThinking, turnText strings.Builder
var calls []agentllm.ToolCall
var usage agentllm.Usage
var thinkStart, thinkEnd, textStart, textEnd time.Time
ev := stream(rc.Context(), route.Model, msgs, decls, streamOpts)
phaseEmitted := false
for e := range ev {
switch e.Kind {
case agentllm.KindTextDelta:
if !phaseEmitted {
rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseResponding})
phaseEmitted = true
}
now := l.now()
if textStart.IsZero() {
textStart = now
}
textEnd = now
turnText.WriteString(e.Text)
rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: textIdx, Text: e.Text})
case agentllm.KindReasoningDelta:
now := l.now()
if thinkStart.IsZero() {
thinkStart = now
}
thinkEnd = now
turnThinking.WriteString(e.ReasoningDelta)
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: thinkingIdx, Text: e.ReasoningDelta})
case agentllm.KindToolCallDelta:
// 累积由 message_end 的 ToolCalls 提供;此处忽略增量。
case agentllm.KindMessageEnd:
calls = e.Message.ToolCalls
usage = e.Usage
case agentllm.KindError:
return fmt.Errorf("llm stream: %s (retryable=%v)", e.ErrorMessage, e.Retryable)
}
}
usageTotal.PromptTokens += usage.PromptTokens
usageTotal.CompletionTokens += usage.CompletionTokens
usageTotal.TotalTokens += usage.TotalTokens
// 记录本轮部件。
if turnThinking.Len() > 0 {
finalParts = append(finalParts, core.Part{Type: core.PartThinking, Text: turnThinking.String(), Process: true, DurationMs: durationMs(thinkStart, thinkEnd)})
thinkingIdx++
}
turnBody := ""
textPartIdx := -1
if turnText.Len() > 0 {
// 工具轮的文本是过程解说(终态随过程折叠);终轮文本才是对用户的回复。
// 部分模型把推理以 <think> 内联混进 content 流:折离为思考部件(过程),
// 保证「最终输出 = 干净的对用户回复」契约。
body, think := splitThinkBlock(turnText.String())
if think != "" {
inlineDur := durationMs(thinkStart, thinkEnd)
if inlineDur == 0 {
inlineDur = durationMs(textStart, textEnd)
}
finalParts = append(finalParts, core.Part{Type: core.PartThinking, Text: think, Process: true, DurationMs: inlineDur})
thinkingIdx++
}
if body != "" {
textPartIdx = len(finalParts)
finalParts = append(finalParts, core.Part{Type: core.PartText, Text: body, Process: len(calls) > 0})
turnBody = body
textIdx++
}
}
if len(calls) == 0 {
// 谎称交付:没产生工件却报文件已生成,或引用了不存在的工件 ID。
// 把这段正文降为过程,纠正一次,再给模型一轮补做或如实说明的机会。
if !deliverableGuardUsed && claimsFalseDeliverable(turnBody, deliverables, rc) {
deliverableGuardUsed = true
if textPartIdx >= 0 {
finalParts[textPartIdx].Process = true
}
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleAssistant, Content: turnText.String()})
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleUser, Content: deliverableGuardNote})
continue
}
// 终轮:无工具调用。
if len(finalParts) == 0 {
finalParts = append(finalParts, core.Part{Type: core.PartText, Text: "(模型未返回内容)"})
}
// 交付物卡片钉尾(内容部件,不折叠):live 投影(live.ts)与落库同构。
for _, ref := range deliverables {
finalParts = append(finalParts, core.Part{Type: core.PartArtifactRef, Artifact: ref})
}
rc.SetAssistantParts(finalParts)
return nil
}
// 工具轮。
rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseActing})
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleAssistant, Content: turnText.String(), ToolCalls: calls, StopReason: agentllm.StopToolCalls})
for _, call := range calls {
toolCallsMade++
if toolCallsMade > maxTools {
return fmt.Errorf("budget: 工具调用超过上限 %d(profile.budget.maxToolCalls)", maxTools)
}
rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: call.ID, Name: call.Name, Arguments: call.Arguments})
callStart := l.now()
var out string
var err error
var artifactRef *core.ArtifactRef
switch call.Name {
case toolSquad:
if !eff.GetBool("runtime.squad", true) {
out = "小队编排已被设置禁用(runtime.squad)"
break
}
var a struct{ Goal string }
json.Unmarshal([]byte(call.Arguments), &a)
dataDir := eff.GetString("storage.dataDir", "./data")
summary, serr := squadadapter.Orchestrate(rc, dataDir, a.Goal)
if serr != nil {
out = "小队编排失败:" + serr.Error()
} else {
out = summary
}
case toolDelegate:
var a struct{ Name, Task, Hint string }
json.Unmarshal([]byte(call.Arguments), &a)
res, serr := rc.SpawnSubagent(orDefault(a.Name, "子代理"), a.Task, a.Hint)
if serr != nil {
out = "子代理失败:" + serr.Error()
} else {
out = orDefault(res.Summary, "子代理完成")
}
case toolPlan:
var p core.PlanSnapshot
if err := json.Unmarshal([]byte(call.Arguments), &p); err == nil {
rc.UpdatePlan(p)
out = "计划已更新"
} else {
out = "计划参数错误: " + err.Error()
}
case toolTodos:
var t core.TodoList
if err := json.Unmarshal([]byte(call.Arguments), &t); err == nil {
rc.UpdateTodos(t)
out = "待办已更新"
} else {
out = "待办参数错误: " + err.Error()
}
case toolCard:
if !cardflow {
// 未声明却被幻觉调用:拒绝执行(不产卡),明确告知模型该能力未启用。
out = "submit_card 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里"
break
}
var a struct{ Title, Summary, Body, Scope string }
if err := json.Unmarshal([]byte(call.Arguments), &a); err != nil || strings.TrimSpace(a.Title) == "" || strings.TrimSpace(a.Body) == "" {
out = "submit_card 参数错误(title/body 必填): " + err.Error()
break
}
body := []core.UINode{{Type: "markdown", Markdown: a.Body}}
if strings.TrimSpace(a.Summary) != "" {
body = append(body, core.UINode{Type: "kv", KV: [][2]string{{"摘要", a.Summary}}})
}
newCard := core.ActivityCard{
Kind: core.CardKindResult, Title: a.Title, Status: core.CardDone, Tone: "ok", Body: body,
}
if a.Scope == "project" {
newCard.ProjectID = core.ID(rc.Run().ProjectID) // 项目级卡(D48):收进项目收件箱
}
card, cerr := rc.SubmitCard(newCard)
if cerr != nil {
out = "submit_card 失败: " + cerr.Error()
break
}
finalParts = append(finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
explicitCard = true
where := "对话"
if a.Scope == "project" {
where = "项目"
}
out = fmt.Sprintf("已作为卡片发出到%s收件箱(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", where, a.Title, card.ID)
case toolCardBegin:
if !cardflow {
out = "card_begin 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里"
break
}
var a struct{ Title, Summary, Scope string }
if err := json.Unmarshal([]byte(call.Arguments), &a); err != nil || strings.TrimSpace(a.Title) == "" {
out = "card_begin 参数错误(title 必填): " + err.Error()
break
}
var body []core.UINode
if strings.TrimSpace(a.Summary) != "" {
body = append(body, core.UINode{Type: "kv", KV: [][2]string{{"摘要", a.Summary}}})
}
newCard := core.ActivityCard{Kind: core.CardKindResult, Title: a.Title, Status: core.CardActive, Tone: "ok", Body: body}
if a.Scope == "project" {
newCard.ProjectID = core.ID(rc.Run().ProjectID) // 项目级卡(D48)
}
card, cerr := rc.BeginStreamCard(newCard)
if cerr != nil {
out = "card_begin 失败: " + cerr.Error()
break
}
out = fmt.Sprintf("流式卡已开始(%s,card:%s)。用 card_append 逐段追加正文,写完调用 card_commit 收尾。", a.Title, card.ID)
case toolCardAppend:
if !cardflow {
out = "card_append 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里"
break
}
var a struct{ Text string }
if err := json.Unmarshal([]byte(call.Arguments), &a); err != nil {
out = "card_append 参数错误: " + err.Error()
break
}
if aerr := rc.AppendStreamCard(a.Text); aerr != nil {
out = "card_append 失败: " + aerr.Error()
break
}
out = "已追加"
case toolCardCommit:
if !cardflow {
out = "card_commit 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里"
break
}
card, cerr := rc.CommitStreamCard()
if cerr != nil {
out = "card_commit 失败: " + cerr.Error()
break
}
finalParts = append(finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
explicitCard = true
out = fmt.Sprintf("已作为卡片发出(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", card.Title, card.ID)
default:
res := rc.ExecuteTool(core.ToolCallRequest{CallID: call.ID, Name: call.Name, Arguments: json.RawMessage(call.Arguments)})
if res.Status != "ok" {
err = fmt.Errorf("%s", res.Output)
} else {
out = res.Output
}
if res.Artifact != nil {
artifactRef = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
// 工具大结果门面卡(D47):截断超限的结果在收件箱留一张可发现
// 的门面(摘要 + 来源 + doc-ref),正文权威仍在工件(不复制字节)。
// 本 run 已显式提交产物卡时避让(防双卡)。
if cardflow && res.Truncated && !explicitCard {
fc, ferr := rc.SubmitCard(artifactFacadeCard(call.Name, *res.Artifact, summarize(out, nil)))
if ferr == nil {
finalParts = append(finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
}
}
}
rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: call.ID, Status: res.Status, Summary: summarize(out, err), Truncated: res.Truncated})
}
if call.Name == toolDelegate || call.Name == toolPlan || call.Name == toolTodos || call.Name == toolSquad || call.Name == toolCard ||
call.Name == toolCardBegin || call.Name == toolCardAppend || call.Name == toolCardCommit {
rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: call.ID, Status: "ok", Summary: summarize(out, err)})
}
summary := summarize(out, nil)
if err != nil {
summary = "error: " + err.Error()
out = summary
}
finalParts = append(finalParts,
core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: call.ID, Name: call.Name, Arguments: call.Arguments, Duration: durationMs(callStart, l.now())}},
core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: call.ID, Status: statusOf(err), Summary: summary}},
)
if artifactRef != nil {
deliverables = append(deliverables, artifactRef)
}
// 回灌工具结果(截断保护)。
if len(out) > 16000 {
out = out[:16000] + "…(超长截断)"
}
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleTool, ToolCallID: call.ID, Name: call.Name, Content: out, IsError: err != nil})
}
}
return fmt.Errorf("budget: 轮次超过上限 %d(profile.budget.maxTurns)", maxTurns)
}
// SubLLMRunner 子代理执行器:轻量模型单轮完成任务并返回结果。
type SubLLMRunner struct {
// Prompts 提示词库(nil = 使用内联回退文案,兼容测试与骨架)。
Prompts *prompts.Service
}
func (l *SubLLMRunner) Name() string { return "llm-subagent" }
// subagentPrompt 子代理 system:库 agent.sub.general 优先;派生方 hint(如小队角色)
// 已含角色要求时(hint 以库角色 ID 标记)换用对应角色模块。
const subagentFallback = "你是被派生的子代理。独立完成给定子任务,输出完整、结构化的结果;需要分点时分条列出,不要为了简短而省略关键内容。"
func (l *SubLLMRunner) subagentPrompt(rc RunCtx) string {
if l.Prompts == nil {
return subagentFallback
}
if body, err := l.Prompts.AgentPrompt(rc.Profile().Owner, "agent.sub.general"); err == nil && strings.TrimSpace(body) != "" {
return body
}
return subagentFallback
}
func (l *SubLLMRunner) Execute(rc RunCtx) error {
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
if route.Model == "" || route.Base == "" {
return fmt.Errorf("llm-subagent: 模型未配置(当前解析:model=%q base=%q)", route.Model, route.Base)
}
rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "执行子任务……", Phase: core.PhaseActing})
client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key}
msgs := []agentllm.Message{
{Role: agentllm.RoleSystem, Content: l.subagentPrompt(rc)},
{Role: agentllm.RoleUser, Content: rc.Subtask()},
}
var sb strings.Builder
for e := range client.Stream(rc.Context(), route.Model, msgs, nil, agentllm.StreamOptions{}) {
if e.Kind == agentllm.KindTextDelta {
sb.WriteString(e.Text)
rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: truncateRun(sb.String(), 80)})
}
if e.Kind == agentllm.KindError {
return fmt.Errorf("subagent stream: %s", e.ErrorMessage)
}
}
rc.SetAssistantParts([]core.Part{{Type: core.PartText, Text: sb.String()}})
return nil
}
func currentUserText(rc RunCtx) string {
hist := rc.History()
for i := len(hist) - 1; i >= 0; i-- {
if hist[i].Role == core.RoleUser {
for _, p := range hist[i].Parts {
if p.Type == core.PartText {
return p.Text
}
}
}
}
return ""
}
func toolList(rc RunCtx) []core.ToolDesc { return rc.Tools() }
func orDefault(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
const deliverableGuardNote = "你的上一段回复声称已经生成或登记了文件,但这一轮没有产生对应的工件。请二选一:用 run_python 写出文件并调用 register_artifact 登记,或如实说明这一轮没有生成文件。不要编造工件 ID,也不要在回复里写 art_ 开头的编号。"
var (
artIDRe = regexp.MustCompile(`art_[0-9a-f]{12}`)
fileClaimRe = regexp.MustCompile(`(?s)(?:已生成|已保存|已导出|已写入).{0,120}?\.(?:docx|xlsx|pptx|pdf|png|jpe?g|csv|md|txt|json)\b`)
)
// claimsFalseDeliverable 判断终轮正文是否在没有对应工件的情况下声称交付。
// 引用本轮已产出、或项目里确实存在的工件,不算谎称。
func claimsFalseDeliverable(body string, produced []*core.ArtifactRef, rc RunCtx) bool {
if strings.TrimSpace(body) == "" {
return false
}
producedIDs := map[string]bool{}
for _, ref := range produced {
if ref != nil && ref.ArtifactID != "" {
producedIDs[ref.ArtifactID] = true
}
}
knownExisting := false
for _, id := range artIDRe.FindAllString(body, -1) {
if producedIDs[id] {
continue
}
if _, _, ok := rc.Artifact(id); ok {
knownExisting = true
continue
}
return true
}
if len(producedIDs) > 0 || knownExisting {
return false
}
return fileClaimRe.MatchString(body)
}
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]) + "…"
}
// 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
}