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.
424 lines
15 KiB
Go
424 lines
15 KiB
Go
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]) + "…"
|
||
}
|