Files
Agent-System-Backend/internal/runtime/llm.go
T
weichenandCursor cd74434b4f Stream tool progress and drop hard agent limits
Why: Tool calls stayed invisible until execution finished, and turn, tool, timeout, and output caps stopped long tasks early.
Evidence: go test ./internal/runtime/ ./internal/tools/ ./internal/prompts/ ./internal/squadadapter/ ./internal/runmgr/ ./internal/app/ ./internal/settings/
Impact: tool.call.delta and tool.output reach the client; default profile, subagents, and squad no longer cap turns or tool calls; exec timeout defaults to 600s with no hard ceiling. A profile or subagent maxTurns above 0 still applies.
Refs: feature/stream-display-overhaul
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 02:17:02 +00:00

474 lines
21 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"
"regexp"
"strings"
"time"
nruntime "agent-native/runtime"
agentllm "agent-native/runtime/llm"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/prompts"
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
}
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)
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) }
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)
if asmReport != nil {
rc.SetContextObservation(core.ContextObservation{
Items: asmReport.Items, Truncations: asmReport.Truncations, EstChars: asmReport.EstChars,
})
}
// 声明工具:编排型内置 + 注册表中画像允许的工具;描述文案库覆盖优先。
// 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})
}
}
maxTurns, maxTools := loopCaps(prof.Budget)
streamOpts := agentllm.StreamOptions{MaxTokens: outputTokenCap(prof.Budget, eff, "models.chat.maxOutputTokens")}
return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools)
}
// 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
}
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 maxDeliverableGuards = 2
const deliverableGuardNote = "你的上一段回复声称已经生成或登记了文件,但这一轮没有产生对应的工件。请二选一:用 run_python 写出文件并调用 register_artifact 登记,或如实说明这一轮没有生成文件。不要编造工件 ID,也不要在回复里写 art_ 开头的编号。正文里的「调用工具」「工具结果」「产出工件」字样不会执行任何东西,调用工具必须用函数调用。"
const discardedClaimNote = "(上一段回复声称已交付,但本轮没有任何工具调用,已作废)"
const deliverableFallbackNotice = "本轮未实际生成文件(模型声称已生成,但没有调用工具)。请重试,或换一种说法再试一次。"
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`)
softClaimRe = regexp.MustCompile(`(?s)(?:文件|文档|工件).{0,40}?已(?:正常)?(?:生成|登记|保存|导出|写入)`)
forgedCallRe = regexp.MustCompile(`\[(?:调用工具|工具结果|产出工件) `)
)
// claimsFalseDeliverable 保留给小队入口:命中即判失败,不走直连路径的纠正轮次。
func claimsFalseDeliverable(body string, produced []*core.ArtifactRef, rc RunCtx) bool {
block, _ := deliverableGate(body, produced, rc)
return block
}
// deliverableGate 判断终轮正文是否在没有对应工件的情况下声称交付。
// 返回值的形状与 Stop 门禁一致,P1 平移进内核时不用再改调用方。
// 引用本轮已产出、或项目里确实存在的工件,不算谎称;正文里的伪造调用标记始终拦截。
func deliverableGate(body string, produced []*core.ArtifactRef, rc RunCtx) (bool, string) {
if strings.TrimSpace(body) == "" {
return false, ""
}
if forgedCallRe.MatchString(body) {
return true, "回复正文里出现了工具调用标记,但本轮没有真实的工具调用"
}
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, ""
}
if fileClaimRe.MatchString(body) || softClaimRe.MatchString(body) {
return true, "回复声称已经生成或登记了文件,但这一轮没有产生对应的工件"
}
return false, ""
}
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
}