cdc5b3a847
把任务执行做成可观测:Dispatcher 在每个节点/阶段发结构化 ExecEvent, 经独立 NATS 通道回流,前端逐节点点亮(状态/耗时/工具入参产出)。 - shared: contract.ExecEvent + ExecSubject(sundynix.exec.<id>,与 Token 流分流); bus.PublishExec/CompleteExec/SubscribeExec(core NATS,复用结束头) - dispatcher: execTracer(自增 Seq 保序 + span 自动计耗时); Orchestrator 加 ExecSink;通用图(init 召回 / 各 tool 入参→产出 / prompt / model 首token+token数)与报告编排(规划大纲 / 各章并行 start-end / 渲染)全程埋点 - gateway: SubscribeExec + GET /tasks/:id/exec SSE(与 token 流并行) - desktop: streamExec + deriveNodes(按 node 归并 start/end/error/info); 复用组件 ExecTrace(竖向轨道,按 kind 着色,运行中脉冲灯); 新 RunsView(运行·观测:轨迹+输出双栏);BottomDrawer 轨迹/工具调用 tab 接真实数据; ReportView 加执行轨迹栏;左导航「运行」置就绪 实测: - 报告任务 /exec:规划(2680ms,4章) → 4 章并行(seq 交错,各~7-8s 重叠=真并行, 每章带 docs 知识库检索预览+成稿字数) → 渲染(docx 落盘) - 通用图 /exec:tool:kb_search(678ms,入参→Milvus 产出) → prompt(2消息) → model(首token 860ms / 4 tokens) - 浏览器(Preview):报告页执行轨迹逐节点点亮、章节带耗时/字数/检索片段,完成后下载 Word Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
213 lines
7.3 KiB
Go
213 lines
7.3 KiB
Go
// Package eino 封装基于 CloudWeGo Eino 的 Agent 图编排引擎。
|
|
package eino
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/cloudwego/eino/schema"
|
|
|
|
"github.com/sundynix/sundynix-dispatcher/internal/dsl"
|
|
"github.com/sundynix/sundynix-dispatcher/internal/harness"
|
|
"github.com/sundynix/sundynix-dispatcher/internal/llm"
|
|
"github.com/sundynix/sundynix-shared/contract"
|
|
)
|
|
|
|
// TokenSink 是 Token 流回流出口(由 NATS bus 实现)。
|
|
type TokenSink interface {
|
|
PublishToken(taskID string, token []byte) error
|
|
CompleteStream(taskID string) error
|
|
}
|
|
|
|
// ToolCaller 经 NATS 调起第 5 层 MCP 工具(由 NATS bus 实现)。
|
|
type ToolCaller interface {
|
|
CallTool(ctx context.Context, subject string, call *contract.ToolCall) (*contract.ToolResult, error)
|
|
}
|
|
|
|
// 工具调用超时;超时即降级(不带工具上下文继续推理)。
|
|
const toolCallTimeout = 3 * time.Second
|
|
|
|
// Orchestrator 把每个 DSL 任务动态编译为 Eino 图并执行(记忆召回 → 工具节点 → 注入 → 流式)。
|
|
type Orchestrator struct {
|
|
pool *llm.Pool
|
|
breaker *harness.CircuitBreaker
|
|
sink TokenSink
|
|
tools ToolCaller
|
|
exec ExecSink
|
|
}
|
|
|
|
// NewOrchestrator 持有依赖;图按任务的 DSL 在 Handle 内动态编译。
|
|
// exec 为执行可视化事件出口(可为 nil,则不发轨迹事件)。
|
|
func NewOrchestrator(pool *llm.Pool, breaker *harness.CircuitBreaker, sink TokenSink, tools ToolCaller, exec ExecSink) (*Orchestrator, error) {
|
|
return &Orchestrator{pool: pool, breaker: breaker, sink: sink, tools: tools, exec: exec}, nil
|
|
}
|
|
|
|
// Handle 消费一个任务:按 DSL 编译 Eino 图并执行,把 Token 流回流到 sundynix.streams.<id>。
|
|
func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
|
|
if !o.breaker.Allow() {
|
|
log.Printf("[eino] circuit open, drop task %s", t.ID)
|
|
return nil
|
|
}
|
|
tr := o.tracer(t.ID)
|
|
defer tr.done()
|
|
|
|
// 报告生成走专用多步编排(规划→分章并行检索撰写→汇聚→渲染 Word),而非通用对话图。
|
|
if intent, _ := t.Meta[contract.MetaIntent].(string); intent == contract.IntentReport {
|
|
return o.handleReport(ctx, t, tr)
|
|
}
|
|
log.Printf("[eino] task %s received (graph=%d bytes), compiling DSL → Eino graph...", t.ID, len(t.Graph))
|
|
tr.info("task", "system", "任务受理", fmt.Sprintf("DSL %d 字节,编译 Eino 图", len(t.Graph)))
|
|
|
|
endCompile := tr.span("compile", "system", "编译 Eino 图")
|
|
run, err := o.compileFlow(ctx, t, tr)
|
|
if err != nil {
|
|
endCompile("", err)
|
|
log.Printf("[eino] task %s compile error: %v", t.ID, err)
|
|
_ = o.sink.CompleteStream(t.ID)
|
|
o.breaker.Report(false)
|
|
return err
|
|
}
|
|
endCompile("图编译完成", nil)
|
|
|
|
stream, err := run.Stream(ctx, t)
|
|
if err != nil {
|
|
tr.emit("model", "model", "error", "模型推理", err.Error(), 0)
|
|
log.Printf("[eino] task %s graph error: %v", t.ID, err)
|
|
_ = o.sink.CompleteStream(t.ID)
|
|
o.breaker.Report(false)
|
|
return err
|
|
}
|
|
defer stream.Close()
|
|
|
|
n := 0
|
|
var answer strings.Builder
|
|
t0 := time.Now()
|
|
for {
|
|
chunk, rerr := stream.Recv()
|
|
if errors.Is(rerr, io.EOF) {
|
|
break
|
|
}
|
|
if rerr != nil {
|
|
log.Printf("[eino] task %s stream recv error: %v", t.ID, rerr)
|
|
break
|
|
}
|
|
if chunk == nil || chunk.Content == "" {
|
|
continue
|
|
}
|
|
if n == 0 {
|
|
tr.emit("model", "model", "start", "模型流式推理", fmt.Sprintf("首 token %dms", time.Since(t0).Milliseconds()), 0)
|
|
}
|
|
if perr := o.sink.PublishToken(t.ID, []byte(chunk.Content)); perr != nil {
|
|
log.Printf("[eino] publish token failed: %v", perr)
|
|
break
|
|
}
|
|
answer.WriteString(chunk.Content)
|
|
n++
|
|
}
|
|
tr.emit("model", "model", "end", "模型流式推理", fmt.Sprintf("%d tokens / %d 字", n, len([]rune(answer.String()))), time.Since(t0).Milliseconds())
|
|
|
|
if cerr := o.sink.CompleteStream(t.ID); cerr != nil {
|
|
log.Printf("[eino] complete stream failed: %v", cerr)
|
|
}
|
|
log.Printf("[eino] task %s done, %d tokens streamed", t.ID, n)
|
|
o.breaker.Report(true)
|
|
|
|
// 写回阶段:流已排空(= 模型生成结束),此处离开热路径、异步落历史 + 抽取记忆。
|
|
// 注:流式节点用 OnEndWithStreamOutput 而非 OnEndFn,故不走回调而在此触发。
|
|
go o.memorize(t, answer.String())
|
|
return nil
|
|
}
|
|
|
|
// fetchMemory 经 MCP memory_get 工具召回用户常驻画像。
|
|
// 工具不可用/超时/无 user_id 时返回空串,降级为无记忆推理(不阻断主流程)。
|
|
func (o *Orchestrator) fetchMemory(ctx context.Context, userID, _ string) string {
|
|
if o.tools == nil || userID == "" {
|
|
return ""
|
|
}
|
|
cctx, cancel := context.WithTimeout(ctx, toolCallTimeout)
|
|
defer cancel()
|
|
res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("memory_get"), &contract.ToolCall{
|
|
Tool: "memory_get",
|
|
Args: map[string]any{"user_id": userID},
|
|
})
|
|
if err != nil {
|
|
log.Printf("[eino] memory_get unavailable for %s, degrade: %v", userID, err)
|
|
return ""
|
|
}
|
|
if !res.OK {
|
|
log.Printf("[eino] memory_get error for %s: %s", userID, res.Error)
|
|
return ""
|
|
}
|
|
log.Printf("[eino] memory_get ok for %s: %s", userID, res.Content)
|
|
return res.Content
|
|
}
|
|
|
|
// fetchHistory 经 MCP history_get 工具召回会话短期多轮历史,转为 Eino 消息。
|
|
// 工具不可用/无 session 时返回空,降级为无历史(不阻断主流程)。
|
|
func (o *Orchestrator) fetchHistory(ctx context.Context, sessionID string) []*schema.Message {
|
|
if o.tools == nil || sessionID == "" {
|
|
return nil
|
|
}
|
|
cctx, cancel := context.WithTimeout(ctx, toolCallTimeout)
|
|
defer cancel()
|
|
res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("history_get"), &contract.ToolCall{
|
|
Tool: "history_get",
|
|
Args: map[string]any{"session_id": sessionID},
|
|
})
|
|
if err != nil || res == nil || !res.OK || res.Content == "" {
|
|
return nil
|
|
}
|
|
var turns []struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
if json.Unmarshal([]byte(res.Content), &turns) != nil {
|
|
return nil
|
|
}
|
|
msgs := make([]*schema.Message, 0, len(turns))
|
|
for _, tn := range turns {
|
|
if tn.Role == "assistant" {
|
|
msgs = append(msgs, schema.AssistantMessage(tn.Content, nil))
|
|
} else {
|
|
msgs = append(msgs, schema.UserMessage(tn.Content))
|
|
}
|
|
}
|
|
if len(msgs) > 0 {
|
|
log.Printf("[eino] history_get ok for %s: %d 条历史", sessionID, len(msgs))
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
// memorize 写回阶段:把本轮对话落进短期历史,并(TODO)抽取长期偏好记忆。
|
|
// 异步执行,离开热路径。
|
|
func (o *Orchestrator) memorize(t *contract.Task, answer string) {
|
|
uid, _ := t.Meta[contract.MetaUserID].(string)
|
|
sid, _ := t.Meta[contract.MetaSessionID].(string)
|
|
if sid != "" && o.tools != nil {
|
|
o.appendHistory(sid, "user", dsl.Compile(t.Graph).Query) // 落真实用户输入,而非 DSL 原文
|
|
o.appendHistory(sid, "assistant", answer)
|
|
log.Printf("[eino] (writeback) task %s 已落会话历史 session=%s", t.ID, sid)
|
|
}
|
|
if uid != "" {
|
|
log.Printf("[eino] (writeback) task %s 待抽取 user=%s 的新偏好记忆", t.ID, uid)
|
|
// TODO: 抽取 LLM → 去重/更新 → memory_upsert
|
|
}
|
|
}
|
|
|
|
func (o *Orchestrator) appendHistory(sessionID, role, content string) {
|
|
cctx, cancel := context.WithTimeout(context.Background(), toolCallTimeout)
|
|
defer cancel()
|
|
if _, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("history_append"), &contract.ToolCall{
|
|
Tool: "history_append",
|
|
Args: map[string]any{"session_id": sessionID, "role": role, "content": content},
|
|
}); err != nil {
|
|
log.Printf("[eino] history_append failed: %v", err)
|
|
}
|
|
}
|