Files
Agent-System-Backend/internal/runmgr/runmgr.go
T
weichen 0d74ec94dd Implement the backend: store/bus/runmgr core
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.
2026-09-29 17:53:24 +00:00

533 lines
18 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 runmgr 是 Run 生命周期枢纽(4.2/4.3):一切运行皆 Run、一切增量皆事件。
// 职责:run 状态机、事件持久化+广播(seq 游标)、HITL 挂起/恢复(三分支路由)、
// 子代理派生(任务树)、工具执行策略(画像三清单 + 确认卡片 + 截断转工件)、取消级联。
package runmgr
import (
"context"
"fmt"
"strings"
"sync"
"time"
core "agent-system-core"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
)
type Manager struct {
st *store.Store
bus *bus.Bus
registry *tools.Registry
profiles *profiles.Service
settings *settings.Service
runner runtime.Runner // conversation runner(mock 或 LLM)
subRunner runtime.Runner // subagent runner(LLM 轻任务)
mock runtime.Runner // 会话脚本回放(演示/测试与 mock 模式)
mockSub runtime.Runner // 子代理脚本回放
mu sync.Mutex
active map[string]*runHandle
}
type runHandle struct {
cancel context.CancelFunc
hitlCh chan hitlAnswer
assistant []core.Part
started time.Time
}
type hitlAnswer struct {
resp core.HITLResponse
err error
}
func New(st *store.Store, b *bus.Bus, reg *tools.Registry, p *profiles.Service, s *settings.Service, runner, subRunner, mockRunner, mockSubRunner runtime.Runner) *Manager {
return &Manager{st: st, bus: b, registry: reg, profiles: p, settings: s, runner: runner, subRunner: subRunner, mock: mockRunner, mockSub: mockSubRunner, active: map[string]*runHandle{}}
}
// ---- runCtx 实现(runtime.RunCtx)----
type runCtx struct {
mgr *Manager
run core.Run
prof core.AgentProfile
eff core.EffectiveSettings
ctx context.Context
h *runHandle
userMessage core.Message
attachments []core.ParsedDocument
subTask string
subHint string
}
func (c *runCtx) Context() context.Context { return c.ctx }
func (c *runCtx) Run() core.Run { return c.run }
func (c *runCtx) Profile() core.AgentProfile { return c.prof }
func (c *runCtx) Settings() core.EffectiveSettings { return c.eff }
func (c *runCtx) History() []core.Message {
hist, _ := c.mgr.st.LastMessages(c.run.ConversationID, 200)
return hist
}
func (c *runCtx) Attachments() []core.ParsedDocument { return c.attachments }
func (c *runCtx) Subtask() string { return c.subTask }
func (c *runCtx) Tools() []core.ToolDesc { return c.mgr.registry.List() }
func (c *runCtx) Emit(typ core.StreamEventType, payload any) error {
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, typ, payload)
return err
}
func (c *runCtx) SetAssistantParts(parts []core.Part) error {
c.mgr.mu.Lock()
c.h.assistant = parts
c.mgr.mu.Unlock()
return nil
}
func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
card.ID = store.NewID("hitl")
card.RunID = c.run.ID
card.ConversationID = c.run.ConversationID
if card.CreatedAt == "" {
card.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
if err := c.mgr.st.SaveHITLCard(&card); err != nil {
return core.HITLResponse{}, err
}
c.mgr.setRunStatus(&c.run, core.RunWaitingHITL, c.run.Phase)
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvHITLRequest, card); err != nil {
return core.HITLResponse{}, err
}
select {
case ans := <-c.h.hitlCh:
return ans.resp, ans.err
case <-c.ctx.Done():
return core.HITLResponse{}, c.ctx.Err()
}
}
func (c *runCtx) SpawnSubagent(name, task, hint string) (core.AgentResultPayload, error) {
return c.mgr.spawnSubagent(c.ctx, c.run, name, task, hint)
}
func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error {
p.PlanID = c.run.ConversationID
if err := c.mgr.st.SavePlan(c.run.ConversationID, p); err != nil {
return err
}
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvPlanUpdate, core.PlanUpdatePayload{Plan: p})
return err
}
func (c *runCtx) UpdateTodos(l core.TodoList) error {
l.ConversationID = c.run.ConversationID
if err := c.mgr.st.SaveTodos(c.run.ConversationID, l); err != nil {
return err
}
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvTodoUpdate, core.TodoUpdatePayload{List: l})
return err
}
func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
art := core.Artifact{ProjectID: c.run.ProjectID, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID}
if err := c.mgr.st.CreateArtifact(&art, content); err != nil {
return art, err
}
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvArtifactCreated, core.ArtifactPayload{Artifact: art}); err != nil {
return art, err
}
// 工件文本进知识索引(全文检索最小实现)。
if kind == "document" && len(content) < 200000 {
_ = c.mgr.st.AddKnowledge(core.KnowledgeDoc{ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: string(content)})
}
return art, nil
}
func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
if q.TopK <= 0 {
q.TopK = int(c.eff.GetNumber("retrieval.topK", 5))
}
if q.ProjectID == "" {
q.ProjectID = c.run.ProjectID
}
return c.mgr.st.SearchKnowledge(q)
}
// ExecuteTool 执行画像策略:deny→错误;confirm→HITL confirm 卡片;执行→截断转工件。
func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
policy := c.prof.Tools
if !policy.ToolAllowed(call.Name) {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", call.Name)}
}
if policy.NeedsConfirm(call.Name) {
var argsPreview string
if len(call.Arguments) > 0 {
argsPreview = truncate(string(call.Arguments), 600)
}
card := core.HITLCard{
Question: fmt.Sprintf("执行工具 %s?", call.Name), Kind: core.HITLKindConfirm,
Choices: []core.HITLChoice{
{ID: "yes", Label: "执行", Description: "按上述参数调用", Impact: "该工具将访问外部资源并返回结果", Recommended: true},
{ID: "no", Label: "取消", Description: "跳过本次调用", Impact: "模型会收到调用被拒绝的说明"},
},
AllowFreeInput: false,
}
_ = argsPreview
resp, err := c.AskHITL(card)
if err != nil {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()}
}
if resp.ChoiceID != "yes" {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"}
}
}
res := c.mgr.registry.Execute(toolCtx{rc: c}, call)
// 超长截断并转工件引用(4.10)。
limit := c.prof.Context.MaxToolResultChars
if limit <= 0 {
limit = int(c.eff.GetNumber("context.maxToolResultChars", 20000))
}
if res.Status == "ok" && len(res.Output) > limit {
if art, err := c.WriteArtifact("tool-"+call.Name+"-"+call.CallID+".txt", "data", "text/plain", []byte(res.Output)); err == nil {
res.Artifact = &art
res.Output = res.Output[:limit] + "\n…(超长,完整结果已存为工件 " + art.Name + ")"
} else {
res.Output = res.Output[:limit] + "…(超长截断,工件写入失败)"
}
res.Truncated = true
}
return res
}
type toolCtx struct{ rc *runCtx }
func (t toolCtx) ProjectID() string { return t.rc.run.ProjectID }
func (t toolCtx) ConversationID() string { return t.rc.run.ConversationID }
func (t toolCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return t.rc.Retrieve(q) }
func (t toolCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
return t.rc.WriteArtifact(name, kind, mime, content)
}
// ---- run 生命周期 ----
// StartConversationRun 为一条已落库的用户消息启动 conversation run。
// attachments 为已解析的附件文档(server 层经 ingest 处理)。
func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument) (core.Run, error) {
prof, _ := m.profiles.Get(conv.ProfileID)
eff := m.settings.Effective(conv.ProjectID)
run := core.Run{
ID: store.NewID("run"), ConversationID: conv.ID, ProjectID: conv.ProjectID,
Kind: core.RunConversation, ProfileID: prof.ID, Status: core.RunPending, Phase: core.PhaseThinking,
}
if err := m.st.CreateRun(&run); err != nil {
return run, err
}
ctx, cancel := context.WithCancel(context.Background())
h := &runHandle{cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()}
m.mu.Lock()
m.active[run.ID] = h
m.mu.Unlock()
runner := m.runner
if eff.GetBool("runtime.mock", false) {
runner = m.mock
}
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments}
go m.execute(rc, runner)
return run, nil
}
// execute 运行至终态并收敛:状态、usage、助手消息、收尾事件。
func (m *Manager) execute(rc *runCtx, runner runtime.Runner) {
defer func() {
m.mu.Lock()
delete(m.active, rc.run.ID)
m.mu.Unlock()
}()
m.setRunStatus(&rc.run, core.RunRunning, core.PhaseThinking)
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseThinking})
execErr := func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("runner panic: %v", r)
}
}()
return runner.Execute(rc)
}()
usage := core.Usage{DurationMs: float64(time.Since(rc.h.started).Milliseconds())}
if execErr != nil {
if rc.ctx.Err() != nil {
m.finishRun(rc, core.RunCancelled, usage, &core.ErrorPart{Code: "run.cancelled", Message: "已取消"})
return
}
m.finishRun(rc, core.RunFailed, usage, &core.ErrorPart{Code: "run.failed", Message: execErr.Error()})
return
}
m.finishRun(rc, core.RunCompleted, usage, nil)
}
func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, errPart *core.ErrorPart) {
now := time.Now().UTC()
rc.run.EndedAt = &now
rc.run.Status = status
rc.run.Phase = core.PhaseDone
rc.run.Usage = &usage
rc.run.Error = errPart
m.st.UpdateRun(&rc.run)
// 终态权威消息树:落库助手消息(含过程部件标记)。
m.mu.Lock()
parts := append([]core.Part{}, rc.h.assistant...)
m.mu.Unlock()
if len(parts) > 0 {
msg := core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: parts}
if err := m.st.AppendMessage(&msg); err == nil {
// 消息树权威已落库;hitl 摘要部件的卡片状态由前端对账收敛。
}
if errPart != nil {
m.st.AppendMessage(&core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartError, Err: errPart}}})
}
} else if errPart != nil {
m.st.AppendMessage(&core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartError, Err: errPart}}})
}
switch status {
case core.RunCompleted:
usagePayload := core.RunCompletedPayload{Usage: usage}
// 把工具调用数补进 usage(由 runner 在部件里体现,这里取事件计数)。
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunCompleted, usagePayload)
case core.RunFailed:
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: errPart.Code, Message: errPart.Message})
}
}
// setRunStatus 更新并广播 phase(状态不单独出事件,phase 事件携带)。
func (m *Manager) setRunStatus(r *core.Run, status core.RunStatus, phase core.RunPhase) {
r.Status = status
r.Phase = phase
m.st.UpdateRun(r)
}
// emit 持久化事件并广播;返回 seq。
func (m *Manager) emit(conversationID, runID string, typ core.StreamEventType, payload any) (int64, error) {
e := core.StreamEvent{
ConversationID: conversationID, RunID: runID,
TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion, Type: typ,
}
seq, err := m.st.AppendEvent(&e, payload)
if err != nil {
return 0, err
}
e.Seq = seq
m.bus.Publish(e)
return seq, nil
}
// CancelRun 取消 run 及其子 run(ctx 级联)。
func (m *Manager) CancelRun(runID string) error {
r, err := m.st.GetRun(runID)
if err != nil {
return err
}
if r.Status == core.RunCompleted || r.Status == core.RunFailed || r.Status == core.RunCancelled {
return fmt.Errorf("runmgr: run %s already terminal (%s)", runID, r.Status)
}
// 取消祖先句柄(子 run 派生自父 ctx,天然级联)。
rootID := r.ParentRunID
if rootID == "" {
rootID = r.ID
}
m.mu.Lock()
if h, ok := m.active[rootID]; ok {
h.cancel()
// 唤醒可能在等 HITL 的执行。
select {
case h.hitlCh <- hitlAnswer{err: context.Canceled}:
default:
}
}
if h, ok := m.active[r.ID]; ok && r.ID != rootID {
h.cancel()
select {
case h.hitlCh <- hitlAnswer{err: context.Canceled}:
default:
}
}
m.mu.Unlock()
return nil
}
// ---- HITL 解析(三分支路由,4.5)----
// ResolveHITL 处理用户对卡片的响应:
// 1) 选项命中 → 回填挂起 run;
// 2) 自由输入以 "/" 开头(命令)→ 取消挂起 run,返回转轨标志由 server 层发送新消息;
// 3) 其他自由输入 → 作为澄清回答回填。
func (m *Manager) ResolveHITL(cardID string, resp core.HITLResponse) (pivotText string, pivoted bool, err error) {
card, err := m.st.GetHITLCard(cardID)
if err != nil {
return "", false, err
}
if card.Resolved {
return "", false, fmt.Errorf("runmgr: card %s already resolved", cardID)
}
run, err := m.st.GetRun(card.RunID)
if err != nil {
return "", false, err
}
kind := "choice"
answer := ""
if resp.ChoiceID != "" {
for _, c := range card.Choices {
if c.ID == resp.ChoiceID {
answer = c.Label
break
}
}
} else if resp.FreeText != "" {
kind = "free"
answer = resp.FreeText
if strings.HasPrefix(strings.TrimSpace(resp.FreeText), "/") {
// 命令转轨:取消挂起 run,等价于用户直接发送该命令。
_ = m.CancelRun(run.ID)
m.st.ResolveHITLCard(cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
return strings.TrimSpace(resp.FreeText), true, nil
}
}
resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind}
if err := m.st.ResolveHITLCard(cardID, resolution); err != nil {
return "", false, err
}
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, resolution)
m.mu.Lock()
h, ok := m.active[run.ID]
m.mu.Unlock()
if !ok {
return "", false, fmt.Errorf("runmgr: run %s not active (may have ended)", run.ID)
}
select {
case h.hitlCh <- hitlAnswer{resp: resp}:
default:
return "", false, fmt.Errorf("runmgr: hitl channel busy")
}
// 恢复运行状态。
if run.Status == core.RunWaitingHITL {
run.Status = core.RunRunning
m.st.UpdateRun(&run)
}
return "", false, nil
}
// ---- 子代理(任务树)----
func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, name, task, hint string) (core.AgentResultPayload, error) {
child := core.Run{
ID: store.NewID("run"), ParentRunID: parent.ID, ConversationID: parent.ConversationID,
ProjectID: parent.ProjectID, Kind: core.RunSubagent, ProfileID: parent.ProfileID,
Status: core.RunPending, Phase: core.PhaseThinking, AgentName: name,
}
if err := m.st.CreateRun(&child); err != nil {
return core.AgentResultPayload{}, err
}
m.emit(parent.ConversationID, parent.ID, core.EvAgentSpawn, core.AgentSpawnPayload{AgentRun: child.ID, Name: name, Task: task})
ctx, cancel := context.WithCancel(parentCtx)
h := &runHandle{cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()}
m.mu.Lock()
m.active[child.ID] = h
m.mu.Unlock()
prof, _ := m.profiles.Get(parent.ProfileID)
eff := m.settings.Effective(parent.ProjectID)
rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint}
m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing)
runner := m.subRunner
if eff.GetBool("runtime.mock", false) || runner == nil {
runner = m.mockSub
}
execErr := func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("subagent panic: %v", r)
}
}()
return runner.Execute(rc)
}()
usage := core.Usage{DurationMs: float64(time.Since(h.started).Milliseconds())}
status := core.RunCompleted
summary := "完成"
payload := core.AgentResultPayload{AgentRun: child.ID, Status: status, Summary: summary}
if execErr != nil {
if ctx.Err() != nil {
status = core.RunCancelled
payload.Status = status
payload.Summary = "已取消"
} else {
status = core.RunFailed
payload.Status = status
payload.Summary = execErr.Error()
}
}
now := time.Now().UTC()
child.EndedAt = &now
child.Status = status
child.Phase = core.PhaseDone
child.Usage = &usage
m.st.UpdateRun(&child)
m.emit(child.ConversationID, child.ID, core.EvAgentResult, payload)
m.mu.Lock()
delete(m.active, child.ID)
m.mu.Unlock()
if status != core.RunCompleted {
return payload, fmt.Errorf("subagent %s: %s", name, payload.Summary)
}
return payload, nil
}
// InterruptRunning 服务停机前把在途 run 标记为 interrupted(4.2 恢复策略)。
func (m *Manager) InterruptRunning() {
m.mu.Lock()
ids := make([]string, 0, len(m.active))
for id := range m.active {
ids = append(ids, id)
}
handles := make([]*runHandle, 0, len(ids))
for _, id := range ids {
if h, ok := m.active[id]; ok {
handles = append(handles, h)
}
}
m.mu.Unlock()
for _, h := range handles {
h.cancel()
}
for _, id := range ids {
r, err := m.st.GetRun(id)
if err != nil || r.Status == core.RunCompleted || r.Status == core.RunFailed || r.Status == core.RunCancelled {
continue
}
r.Status = core.RunInterrupted
m.st.UpdateRun(&r)
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}