Files
sundynix-agentix/sundynix-dispatcher/internal/eino/orchestrator.go
T
Blizzard 71db0e295f feat: compose.NewGraph 全图编译 — 工具节点在 Eino 图里真实执行
dispatcher 按每个任务的 DSL 动态编译 Eino 图:工具/检索节点按拓扑序作为真实图
节点经 NATS 调 MCP,产出注入模型上下文。不再是固定的 recall→prompt→model。

- dsl: 加 Parse(图结构) + (Flow)Topo(Kahn 拓扑序,环退化声明序) + ToolBinding(tool/
  retriever 节点→工具名+参数)
- eino/compile.go: 逐任务 compileFlow —— START→init(身份+记忆召回)→tool_n(真调 MCP,
  失败降级)→prompt(黑板 RunCtx 组装 system+画像+工具产出+历史+输入)→model→END
- eino/orchestrator: 去掉启动期静态图,Handle 内按 DSL 动态编译;删旧 graph.go/state.go
- 工具节点产出作为参考资料注入 system,模型据此作答
- 验证: 全模块 build✓ + e2e PASS; 真实 DeepSeek 双证——回归(input+agent)→'蓝色';
  工具节点(echo 注入事实)→mcp-go 日志证明图里真调 echo→模型据参考资料答'…Milvus…'

注: 分支/并行节点(compose.Branch/fan-out)暂未编译,是更大 TODO。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 16:45:33 +08:00

193 lines
6.3 KiB
Go

// Package eino 封装基于 CloudWeGo Eino 的 Agent 图编排引擎。
package eino
import (
"context"
"encoding/json"
"errors"
"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
}
// NewOrchestrator 持有依赖;图按任务的 DSL 在 Handle 内动态编译。
func NewOrchestrator(pool *llm.Pool, breaker *harness.CircuitBreaker, sink TokenSink, tools ToolCaller) (*Orchestrator, error) {
return &Orchestrator{pool: pool, breaker: breaker, sink: sink, tools: tools}, 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
}
log.Printf("[eino] task %s received (graph=%d bytes), compiling DSL → Eino graph...", t.ID, len(t.Graph))
run, err := o.compileFlow(ctx, t)
if err != nil {
log.Printf("[eino] task %s compile error: %v", t.ID, err)
_ = o.sink.CompleteStream(t.ID)
o.breaker.Report(false)
return err
}
stream, err := run.Stream(ctx, t)
if err != nil {
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
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 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++
}
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)
}
}