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>
732 lines
30 KiB
Go
732 lines
30 KiB
Go
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
|
||
}
|