Files
Agent-System-Backend/internal/runtime/llm.go
T
weichen 4938bd095c Move prompts into a layered data library with trigger-based assembly
Why: prompts lived inline in Go (profile const, subagent string, squad
role one-liners, tool descriptions) - every copy change shipped code,
and task guidance like meeting-minutes quality lived permanently in the
base prompt whether or not the task was at hand.

Evidence: new prompts package tests (library integrity, default
assembly, skill triggering incl. attachment gate and command bypass,
priority supersession, override chain drift, command match, frontmatter
parse); composite make test green; runtime check on a live server
(40 modules across 7 layers, override round-trip, /minutes starts a
run instead of hitting the command bus).

Impact: 40 builtin modules as embed data (5 base + 1 env + 12 task
skills + 10 agent roles + 7 tool descriptions + 3 aux templates + 2
styles); migration v8 adds prompt_modules for user overrides with
basedOnVersion drift markers; ctxasm assembles multi-segment system
messages with per-module observability; subagent/squad/tool copy now
resolve from the library with inline fallbacks; skill commands surface
in the / menu and route to runs; default profile SystemPrompt empties
onto the library base; pins deps/agent-system-core to the contract
commit.

Refs: feature/prompt-library task card; design guide in Documents.
2026-09-30 10:35:23 +00:00

424 lines
15 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"
"strings"
"time"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/prompts"
"agent-system-backend/internal/squadadapter"
)
// LLMRunner 主对话执行器:复用 agent-native 的 OpenAI 兼容流式客户端(不自研接入层)。
// 多轮循环:流式 → 工具调用(画像策略)→ 回灌结果 → 直至无工具调用或预算耗尽。
// 内置三个编排型工具(不经注册表):delegate_task(子代理)、update_plan、update_todos。
type LLMRunner struct {
// Prompts 提示词库(nil = 回退画像 SystemPrompt 单段,兼容测试与骨架)。
Prompts *prompts.Service
}
func (l *LLMRunner) Name() string { return "llm" }
const (
toolDelegate = "delegate_task"
toolPlan = "update_plan"
toolTodos = "update_todos"
toolSquad = "squad_orchestrate"
)
// 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,
}
if len(names) > 0 {
env.Attachments = strings.Join(names, "、")
}
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() + ")"}
}
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
}
// 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
}
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)
}
client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key}
// 知识注入:当前消息包含 @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)
}
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs)
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"]}`)},
}
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
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
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
ev := client.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
}
turnText.WriteString(e.Text)
rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: textIdx, Text: e.Text})
case agentllm.KindReasoningDelta:
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})
thinkingIdx++
}
if turnText.Len() > 0 {
finalParts = append(finalParts, core.Part{Type: core.PartText, Text: turnText.String()})
textIdx++
}
if len(calls) == 0 {
// 终轮:无工具调用。
if len(finalParts) == 0 {
finalParts = append(finalParts, core.Part{Type: core.PartText, Text: "(模型未返回内容)"})
}
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})
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()
}
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}
}
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 {
rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: call.ID, Status: "ok", Summary: summarize(out, err)})
}
summary := ""
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}},
core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: call.ID, Status: statusOf(err), Summary: summary}},
)
if artifactRef != nil {
finalParts = append(finalParts, core.Part{Type: core.PartArtifactRef, Artifact: 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
}
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]) + "…"
}