Why: attachments were hard-truncated at 16k chars (most of a meeting transcript silently dropped), injection carried no task guidance, the builtin profile prompt was one generic line, subagents were capped at 300 chars, and docx tables were appended after all paragraphs losing document order - together these produced sparse meeting minutes. Evidence: go test ./... 16 packages ok incl. new ctxasm limit tests and docx order assertions (heading->para->table->para); composite make test green over the pinned combo. Impact: attachment ceiling is 48k chars (settings context.maxAttachmentChars), truncated on rune boundaries and observable in the context report; the builtin prompt demands full-coverage structured markdown for document tasks and upgrades stale builtin rows on boot; budget.maxTokens now reaches the provider; subagents may write full-length results; docx tables stay in place. Refs: feature/chat-live-quality task card.
318 lines
12 KiB
Go
318 lines
12 KiB
Go
package runtime
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
agentllm "agent-native/runtime/llm"
|
||
core "agent-system-core"
|
||
"agent-system-backend/internal/ctxasm"
|
||
"agent-system-backend/internal/squadadapter"
|
||
)
|
||
|
||
// LLMRunner 主对话执行器:复用 agent-native 的 OpenAI 兼容流式客户端(不自研接入层)。
|
||
// 多轮循环:流式 → 工具调用(画像策略)→ 回灌结果 → 直至无工具调用或预算耗尽。
|
||
// 内置三个编排型工具(不经注册表):delegate_task(子代理)、update_plan、update_todos。
|
||
type LLMRunner struct{}
|
||
|
||
func (l *LLMRunner) Name() string { return "llm" }
|
||
|
||
const (
|
||
toolDelegate = "delegate_task"
|
||
toolPlan = "update_plan"
|
||
toolTodos = "update_todos"
|
||
toolSquad = "squad_orchestrate"
|
||
)
|
||
|
||
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
|
||
}
|
||
}
|
||
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge)
|
||
if asmReport != nil {
|
||
rc.SetContextObservation(core.ContextObservation{
|
||
Items: asmReport.Items, Truncations: asmReport.Truncations, EstChars: asmReport.EstChars,
|
||
})
|
||
}
|
||
|
||
// 声明工具:编排型内置 + 注册表中画像允许的工具。
|
||
decls := []agentllm.ToolDecl{
|
||
{Name: toolSquad, Description: "用小队编排引擎执行多步骤目标:规则规划器拆解为 梳理→专项→成文→终审 步骤并逐步执行(每步一个子代理),挂起决策会以提问卡片征求用户", Parameters: json.RawMessage(`{"type":"object","properties":{"goal":{"type":"string","description":"要完成的整体目标描述"}},"required":["goal"]}`)},
|
||
{Name: toolDelegate, Description: "派生一个子代理执行独立子任务(如检索、撰写),返回其结果摘要", Parameters: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string","description":"子代理角色名"},"task":{"type":"string","description":"子任务描述"},"hint":{"type":"string","description":"执行提示"}},"required":["name","task"]}`)},
|
||
{Name: toolPlan, Description: "创建或更新当前会话的计划(步骤列表)", 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: "更新当前会话的待办清单", 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) {
|
||
toolDescs = append(toolDescs, t)
|
||
decls = append(decls, agentllm.ToolDecl{Name: t.Name, Description: t.Description, Parameters: t.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{}
|
||
|
||
func (l *SubLLMRunner) Name() string { return "llm-subagent" }
|
||
|
||
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: "你是被派生的子代理。独立完成给定子任务,输出完整、结构化的结果;需要分点时分条列出,不要为了简短而省略关键内容。"},
|
||
{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]) + "…"
|
||
}
|