Details (original message): Implement the backend: store/bus/runmgr core, mock and LLM runners (agent-native client), HITL three-branch routing, subagent runs, builtin and declarative HTTP tools, ingest parsers (md/txt/docx/pdf-text), knowledge fulltext, layered settings, profiles, plugin host, REST+SSE API, single-binary entrypoint; e2e and unit tests.
301 lines
11 KiB
Go
301 lines
11 KiB
Go
package runtime
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
agentllm "agent-native/runtime/llm"
|
||
core "agent-system-core"
|
||
"agent-system-backend/internal/ctxasm"
|
||
)
|
||
|
||
// 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"
|
||
)
|
||
|
||
func (l *LLMRunner) Execute(rc RunCtx) error {
|
||
eff := rc.Settings()
|
||
chat := eff.GetString("models.chat.model", "")
|
||
base := eff.GetString("models.chat.baseUrl", "")
|
||
key := eff.GetString("models.chat.apiKey", "")
|
||
if chat == "" || base == "" {
|
||
return fmt.Errorf("llm: 主模型未配置(settings: models.chat.baseUrl / models.chat.model;演示模式可开 runtime.mock)")
|
||
}
|
||
|
||
client := &agentllm.Client{BaseURL: base, APIKey: key}
|
||
prof := rc.Profile()
|
||
|
||
// 知识注入:当前消息包含 @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, _ := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge)
|
||
|
||
// 声明工具:编排型内置 + 注册表中画像允许的工具。
|
||
decls := []agentllm.ToolDecl{
|
||
{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
|
||
}
|
||
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(), chat, msgs, decls, agentllm.StreamOptions{})
|
||
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 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 {
|
||
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 {
|
||
eff := rc.Settings()
|
||
model := eff.GetString("models.lightweight.model", "")
|
||
base := eff.GetString("models.lightweight.baseUrl", "")
|
||
key := eff.GetString("models.lightweight.apiKey", "")
|
||
if model == "" || base == "" {
|
||
model = eff.GetString("models.chat.model", "")
|
||
base = eff.GetString("models.chat.baseUrl", "")
|
||
key = eff.GetString("models.chat.apiKey", "")
|
||
}
|
||
if model == "" || base == "" {
|
||
return fmt.Errorf("llm-subagent: 模型未配置")
|
||
}
|
||
rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "执行子任务……", Phase: core.PhaseActing})
|
||
client := &agentllm.Client{BaseURL: base, APIKey: key}
|
||
msgs := []agentllm.Message{
|
||
{Role: agentllm.RoleSystem, Content: "你是被派生的子代理。独立完成给定子任务,输出简明结果摘要(不超过 300 字)。"},
|
||
{Role: agentllm.RoleUser, Content: rc.Subtask()},
|
||
}
|
||
var sb strings.Builder
|
||
for e := range client.Stream(rc.Context(), 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]) + "…"
|
||
}
|