From 71102d2424b9480efc2b61a7b0a4694c6126b095 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 23 Jun 2026 09:48:44 +0800 Subject: [PATCH 01/15] =?UTF-8?q?feat(dispatcher,gateway):=20Eino=20?= =?UTF-8?q?=E9=87=87=E7=BA=B3=20Phase=20D=20=E2=80=94=E2=80=94=20=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit submitted → running → done / failed / timeout 显式状态机,根治"卡运行中 看不出来"(此前 Task.Status 只写死 submitted、从不流转)。 - contract:新增 SubjectTaskStatus 主题 + TaskStatusEvent + 状态常量 - shared/bus:PublishTaskStatus / SubscribeTaskStatus(core NATS pub-sub) - dispatcher:StatusSink 接口 + Orchestrator.Handle 状态钩子——进入执行 →running、收尾 finishStatus→done/failed、整体超时上限 taskExecTimeout =3min→timeout;经 sub 回写 - gateway:SubscribeTaskStatus 落 PG(Task 增 Detail 字段,AutoMigrate); 新增 GET /api/v1/tasks/:id 供 UI 轮询状态 验收:实测 submitted→running→done 流转 + PG 持久化 + 端点查询闭环; make test-go 全绿。HITL 中断恢复 / 多智能体仍按场景待做。 Co-Authored-By: Claude Opus 4.8 (1M context) --- EINO_ADOPTION.md | 12 ++--- sundynix-dispatcher/cmd/dispatcher/main.go | 4 +- .../internal/eino/orchestrator.go | 53 +++++++++++++++++-- .../internal/nats/subscriber.go | 8 +++ sundynix-gateway/cmd/server/main.go | 9 ++++ .../internal/handler/task_handler.go | 11 ++++ sundynix-gateway/internal/nats/publisher.go | 5 ++ sundynix-gateway/internal/router/router.go | 1 + sundynix-gateway/internal/store/models.go | 3 +- sundynix-gateway/internal/store/pgsql.go | 26 ++++++++- sundynix-shared/bus/bus.go | 25 +++++++++ sundynix-shared/contract/task.go | 24 +++++++++ 12 files changed, 166 insertions(+), 15 deletions(-) diff --git a/EINO_ADOPTION.md b/EINO_ADOPTION.md index 2895ff4..3acb352 100644 --- a/EINO_ADOPTION.md +++ b/EINO_ADOPTION.md @@ -10,7 +10,7 @@ - [x] **Phase A · 地基**:`llm.Pool` 换 Eino ChatModel 组件(commit d84b1ec,验收通过) - [x] **Phase B · 质变**:MCP 工具→`InvokableTool` + ReAct agent(模型自主调工具,验收 7/7 命中) - [ ] **Phase C · 编排归一**:`Flow→compose.Graph` + callbacks 桥接 -- [ ] **Phase D · 状态化执行**:任务生命周期 FSM / HITL 中断恢复 / 多智能体 +- [~] **Phase D · 状态化执行**:✅ 任务生命周期 FSM(已完成)/ ⬜ HITL 中断恢复 / ⬜ 多智能体(按场景) --- @@ -139,11 +139,11 @@ github.com/cloudwego/eino-ext/... # ⚠️ 官方组件实现(open 主题:把"执行"从一次性 DAG 升级为**可持久化、可恢复的状态机**。三件事同一条线,一起做。 -- **任务生命周期 FSM** 🆕:现在 `Task.Status` 只写死 `submitted`、全仓从不流转(`store/models.go` + `pgsql.go:96`),是个摆设——这正是"卡运行中看不出来"的根因。 - - 设计:`submitted → running → done / failed / timeout` 显式状态机。 - - dispatcher 开跑/跑完/出错 经 NATS 回写状态(新增 `sundynix.tasks.status` 或复用 exec 流),网关落 PG 并推给 UI。 - - 收益:管理端「服务状态」/ 桌面端能看到任务真实进度,超时自动翻红,无需人工猜。 - - 与下面同源:Eino compose 的 graph state + 节点级状态正好承载它。 +- **任务生命周期 FSM** ✅ 已完成:`submitted → running → done / failed / timeout` 显式状态机。 + - dispatcher 经新主题 `sundynix.tasks.status` 回写(`TaskStatusEvent`):进入执行→running、收尾→done/failed、整体超时上限 `taskExecTimeout=3min`→timeout;网关 `SubscribeTaskStatus` 落 PG(`Task.Status/Detail`)。 + - UI 轮询:`GET /api/v1/tasks/:id` 返回 `{status, detail}`。 + - 验收:submitted→running→done 实测流转、PG 持久化、3min 超时兜底——根治"卡运行中看不出来"。 + - 桌面端轮询接线仍可补(当前后端 + 端点已就绪)。 - **中断/恢复(HITL)**:审批型工业流程(生成中途人工确认)。需 checkpoint 持久化(PG/Redis)。等有具体审批用例再做。 - **多智能体协同**(`flow/agent/multiagent`):出现真实多角色编排需求时再上,现在无用例。 diff --git a/sundynix-dispatcher/cmd/dispatcher/main.go b/sundynix-dispatcher/cmd/dispatcher/main.go index 65a0620..c0caa6f 100644 --- a/sundynix-dispatcher/cmd/dispatcher/main.go +++ b/sundynix-dispatcher/cmd/dispatcher/main.go @@ -41,8 +41,8 @@ func main() { log.Printf("[dispatcher] subscribe model config: %v", err) } - // sub 同时作为 Token 回流出口(TokenSink)、MCP 工具调用出口(ToolCaller)与执行事件出口(ExecSink)。 - orch, err := eino.NewOrchestrator(pool, breaker, eval, sub, sub, sub) + // sub 同时作为 Token 回流(TokenSink)、MCP 工具调用(ToolCaller)、执行事件(ExecSink)与任务状态回写(StatusSink)出口。 + orch, err := eino.NewOrchestrator(pool, breaker, eval, sub, sub, sub, sub) if err != nil { log.Fatalf("[dispatcher] build eino graph: %v", err) } diff --git a/sundynix-dispatcher/internal/eino/orchestrator.go b/sundynix-dispatcher/internal/eino/orchestrator.go index 5e6e197..1a5428d 100644 --- a/sundynix-dispatcher/internal/eino/orchestrator.go +++ b/sundynix-dispatcher/internal/eino/orchestrator.go @@ -4,6 +4,7 @@ package eino import ( "context" "encoding/json" + "errors" "fmt" "log" "sync" @@ -29,6 +30,11 @@ type ToolCaller interface { CallTool(ctx context.Context, subject string, call *contract.ToolCall) (*contract.ToolResult, error) } +// StatusSink 回写任务生命周期状态(由 NATS bus 实现;可为 nil → 不回写)。 +type StatusSink interface { + PublishTaskStatus(taskID, status, detail string) error +} + // LLM 是编排所需的语言模型能力(生产由 *llm.Pool 实现)。抽成接口便于测试注入假模型。 type LLM interface { Ready() bool @@ -42,6 +48,9 @@ type LLM interface { // 工具调用超时;超时即降级(不带工具上下文继续推理)。 const toolCallTimeout = 3 * time.Second +// taskExecTimeout 是单个任务整体执行上限;超时即判 timeout(状态机),避免无限期"运行中"。 +const taskExecTimeout = 3 * time.Minute + // Orchestrator 把每个 DSL 任务动态编译为 Eino 图并执行(记忆召回 → 工具节点 → 注入 → 流式)。 type Orchestrator struct { pool LLM @@ -50,15 +59,39 @@ type Orchestrator struct { sink TokenSink tools ToolCaller exec ExecSink + status StatusSink // 任务生命周期状态回写(可为 nil) turnMu sync.Mutex // 保护 turns(攒批计数,多任务 goroutine 共享) turns map[string]int // sessionID → 累计轮次,用于每 N 轮触发 consolidate } // NewOrchestrator 持有依赖;图按任务的 DSL 在 Handle 内动态编译。 -// exec 为执行可视化事件出口(可为 nil,则不发轨迹事件);eval 为自动化评测(可为 nil)。 -func NewOrchestrator(pool LLM, breaker *harness.CircuitBreaker, eval *harness.Evaluator, sink TokenSink, tools ToolCaller, exec ExecSink) (*Orchestrator, error) { - return &Orchestrator{pool: pool, breaker: breaker, eval: eval, sink: sink, tools: tools, exec: exec}, nil +// exec 为执行可视化事件出口(可为 nil,则不发轨迹事件);eval 为自动化评测(可为 nil); +// status 为任务生命周期状态回写出口(可为 nil)。 +func NewOrchestrator(pool LLM, breaker *harness.CircuitBreaker, eval *harness.Evaluator, sink TokenSink, tools ToolCaller, exec ExecSink, status StatusSink) (*Orchestrator, error) { + return &Orchestrator{pool: pool, breaker: breaker, eval: eval, sink: sink, tools: tools, exec: exec, status: status}, nil +} + +// setStatus 回写一次任务状态流转(status 为 nil 时静默跳过)。 +func (o *Orchestrator) setStatus(taskID, status, detail string) { + if o.status == nil { + return + } + if err := o.status.PublishTaskStatus(taskID, status, detail); err != nil { + log.Printf("[eino] 回写任务状态 %s=%s 失败: %v", taskID, status, err) + } +} + +// finishStatus 据收尾错误把任务置为 done / timeout / failed。 +func (o *Orchestrator) finishStatus(taskID string, err error) { + switch { + case err == nil: + o.setStatus(taskID, contract.TaskDone, "") + case errors.Is(err, context.DeadlineExceeded): + o.setStatus(taskID, contract.TaskTimeout, "执行超时") + default: + o.setStatus(taskID, contract.TaskFailed, truncate(err.Error(), 200)) + } } // Handle 消费一个任务:按 DSL 编译 Eino 图并执行,把 Token 流回流到 sundynix.streams.。 @@ -72,22 +105,31 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error { tr.info("task", "system", "服务熔断", "后端连续失败,暂时拒绝新任务,请稍后重试") _ = o.sink.PublishToken(t.ID, []byte("⚠️ 服务繁忙(已触发熔断保护),请稍后重试。")) _ = o.sink.CompleteStream(t.ID) + o.setStatus(t.ID, contract.TaskFailed, "服务熔断") return nil } + // 任务状态机:进入执行 → running;整体加超时上限,超时判 timeout(杜绝无限期"运行中")。 + o.setStatus(t.ID, contract.TaskRunning, "") + tctx, cancel := context.WithTimeout(ctx, taskExecTimeout) + defer cancel() + // 报告生成走专用多步编排(规划→分章并行检索撰写→汇聚→渲染 Word),而非通用对话图。 if intent, _ := t.Meta[contract.MetaIntent].(string); intent == contract.IntentReport { - return o.handleReport(ctx, t, tr) + err := o.handleReport(tctx, t, tr) + o.finishStatus(t.ID, err) + return err } log.Printf("[eino] task %s received (graph=%d bytes), 按图执行(拓扑+连线+分支)...", t.ID, len(t.Graph)) tr.info("task", "system", "任务受理", fmt.Sprintf("DSL %d 字节,按图执行", len(t.Graph))) // 按 DSL 图的真实拓扑/连线/分支执行(graph.go 解释器),agent 节点流式回流 token。 - answer, err := o.runGraph(ctx, t, tr) + answer, err := o.runGraph(tctx, t, tr) if err != nil { log.Printf("[eino] task %s graph error: %v", t.ID, err) _ = o.sink.CompleteStream(t.ID) o.breaker.Report(false) + o.finishStatus(t.ID, err) return err } @@ -96,6 +138,7 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error { } log.Printf("[eino] task %s done (%d 字答复)", t.ID, len([]rune(answer))) o.breaker.Report(true) + o.finishStatus(t.ID, nil) // 写回阶段:离开热路径、异步落历史 + (TODO)抽取记忆。 go o.memorize(t, answer) diff --git a/sundynix-dispatcher/internal/nats/subscriber.go b/sundynix-dispatcher/internal/nats/subscriber.go index 99f3db9..9353e1a 100644 --- a/sundynix-dispatcher/internal/nats/subscriber.go +++ b/sundynix-dispatcher/internal/nats/subscriber.go @@ -4,6 +4,7 @@ package nats import ( "context" "log" + "time" sharedbus "github.com/sundynix/sundynix-shared/bus" "github.com/sundynix/sundynix-shared/contract" @@ -73,6 +74,13 @@ func (s *Subscriber) ServeHealth(provide func() []byte) (func() error, error) { return s.inner.ServeHealth(contract.SubjectHealthDispatcher, provide) } +// PublishTaskStatus 让 Subscriber 满足 eino.StatusSink,把任务状态流转回写给网关。 +func (s *Subscriber) PublishTaskStatus(taskID, status, detail string) error { + return s.inner.PublishTaskStatus(&contract.TaskStatusEvent{ + TaskID: taskID, Status: status, Detail: detail, TS: time.Now().UnixMilli(), + }) +} + // RequestModelConfig 向控制面(Gateway)取当前激活的对话模型配置。 func (s *Subscriber) RequestModelConfig(ctx context.Context) (*contract.ModelConfig, error) { return s.inner.RequestConfig(ctx, contract.ConfigKindChat) diff --git a/sundynix-gateway/cmd/server/main.go b/sundynix-gateway/cmd/server/main.go index 7652a0a..53c52bd 100644 --- a/sundynix-gateway/cmd/server/main.go +++ b/sundynix-gateway/cmd/server/main.go @@ -46,6 +46,15 @@ func main() { } } + // 任务生命周期:订阅 dispatcher 回写的状态流转(running/done/failed/timeout),落 PG 供 UI 查询。 + if _, err := bus.SubscribeTaskStatus(func(ev *contract.TaskStatusEvent) { + if err := db.UpdateTaskStatus(context.Background(), ev.TaskID, ev.Status, ev.Detail); err != nil { + log.Printf("[gateway] 更新任务状态 %s=%s 失败: %v", ev.TaskID, ev.Status, err) + } + }); err != nil { + log.Printf("[gateway] subscribe task status: %v", err) + } + r := router.New(db, cache, bus, blobStore) addr := envOr("GATEWAY_ADDR", ":8080") log.Printf("[gateway] listening on %s", addr) diff --git a/sundynix-gateway/internal/handler/task_handler.go b/sundynix-gateway/internal/handler/task_handler.go index 28eca24..43c98f3 100644 --- a/sundynix-gateway/internal/handler/task_handler.go +++ b/sundynix-gateway/internal/handler/task_handler.go @@ -56,6 +56,17 @@ func (h *Handler) SubmitTask(c *gin.Context) { c.JSON(http.StatusAccepted, gin.H{"task_id": task.ID}) } +// TaskStatus: GET /api/v1/tasks/:id —— 返回任务生命周期状态(供 UI 轮询,根治"卡运行中看不出来")。 +func (h *Handler) TaskStatus(c *gin.Context) { + id := c.Param("id") + status, detail := h.db.GetTaskStatus(c.Request.Context(), id) + if status == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"task_id": id, "status": status, "detail": detail}) +} + // StreamTask: 订阅 sundynix.streams.,以 SSE 把零拷贝 Token Stream 推给客户端。 func (h *Handler) StreamTask(c *gin.Context) { taskID := c.Param("id") diff --git a/sundynix-gateway/internal/nats/publisher.go b/sundynix-gateway/internal/nats/publisher.go index cd328f8..181a8ea 100644 --- a/sundynix-gateway/internal/nats/publisher.go +++ b/sundynix-gateway/internal/nats/publisher.go @@ -59,6 +59,11 @@ func (b *Bus) Ping(ctx context.Context, subject string) ([]byte, error) { return b.inner.Ping(ctx, subject) } +// SubscribeTaskStatus 订阅 dispatcher 回写的任务生命周期状态(落 PG)。 +func (b *Bus) SubscribeTaskStatus(onEvent func(*contract.TaskStatusEvent)) (func() error, error) { + return b.inner.SubscribeTaskStatus(onEvent) +} + // ServeConfig 让网关作为配置控制面,响应某 kind 的配置请求。 func (b *Bus) ServeConfig(kind string, provide func() *contract.ModelConfig) (func() error, error) { return b.inner.ServeConfig(kind, provide) diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 0bb7275..1bff4f2 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -49,6 +49,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. p := api.Group("", middleware.RequireAuth()) { p.POST("/tasks", h.SubmitTask) // 解析 DSL 并 Publish 到 NATS(带已验证 uid) + p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout) p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert) p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板) p.DELETE("/memory", h.DeleteMemory) // 软删一条偏好(?key=) diff --git a/sundynix-gateway/internal/store/models.go b/sundynix-gateway/internal/store/models.go index fbe4ae1..81092f3 100644 --- a/sundynix-gateway/internal/store/models.go +++ b/sundynix-gateway/internal/store/models.go @@ -19,5 +19,6 @@ type Task struct { BaseModel TaskID string `gorm:"uniqueIndex;size:64"` // task_xxx Graph string `gorm:"type:jsonb"` // React Flow 导出的 DSL 原文 - Status string `gorm:"size:32"` // submitted / done / failed + Status string `gorm:"size:32"` // submitted / running / done / failed / timeout + Detail string `gorm:"type:text"` // 失败/超时原因等(状态机回写) } diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go index 0e4c751..8b011f7 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -9,6 +9,8 @@ import ( "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/schema" + + "github.com/sundynix/sundynix-shared/contract" ) // errStoreDisabled 表示 Postgres 处于降级(未连接)模式,写操作无法进行。 @@ -93,7 +95,29 @@ func (p *Postgres) SaveTask(ctx context.Context, id, graph string) error { if p.db == nil { return nil } - return p.db.WithContext(ctx).Create(&Task{TaskID: id, Graph: graph, Status: "submitted"}).Error + return p.db.WithContext(ctx).Create(&Task{TaskID: id, Graph: graph, Status: contract.TaskSubmitted}).Error +} + +// UpdateTaskStatus 流转任务状态(running/done/failed/timeout),由 dispatcher 经 NATS 回写驱动。 +func (p *Postgres) UpdateTaskStatus(ctx context.Context, id, status, detail string) error { + if p.db == nil { + return nil + } + return p.db.WithContext(ctx).Model(&Task{}). + Where("task_id = ?", id). + Updates(map[string]any{"status": status, "detail": detail}).Error +} + +// GetTaskStatus 取一条任务的当前状态(供 UI 轮询;不存在返回空串)。 +func (p *Postgres) GetTaskStatus(ctx context.Context, id string) (status, detail string) { + if p.db == nil { + return "", "" + } + var t Task + if err := p.db.WithContext(ctx).Select("status", "detail").Where("task_id = ?", id).First(&t).Error; err != nil { + return "", "" + } + return t.Status, t.Detail } // CountTasks 返回已提交任务数(降级模式返回 0)。 diff --git a/sundynix-shared/bus/bus.go b/sundynix-shared/bus/bus.go index 63631cf..dca7377 100644 --- a/sundynix-shared/bus/bus.go +++ b/sundynix-shared/bus/bus.go @@ -237,6 +237,31 @@ func (b *Bus) Ping(ctx context.Context, subject string) ([]byte, error) { return msg.Data, nil } +// ---- 任务生命周期状态回写(core NATS pub-sub)---- + +// PublishTaskStatus 广播一次任务状态流转(dispatcher 调用)。 +func (b *Bus) PublishTaskStatus(ev *contract.TaskStatusEvent) error { + data, err := json.Marshal(ev) + if err != nil { + return err + } + return b.nc.Publish(contract.SubjectTaskStatus, data) +} + +// SubscribeTaskStatus 订阅任务状态流转(网关调用,落 PG + 推 UI)。 +func (b *Bus) SubscribeTaskStatus(onEvent func(*contract.TaskStatusEvent)) (unsub func() error, err error) { + sub, err := b.nc.Subscribe(contract.SubjectTaskStatus, func(m *nats.Msg) { + var ev contract.TaskStatusEvent + if json.Unmarshal(m.Data, &ev) == nil { + onEvent(&ev) + } + }) + if err != nil { + return nil, fmt.Errorf("subscribe task status: %w", err) + } + return sub.Unsubscribe, nil +} + // ---- 配置控制面(core NATS request-reply + broadcast)---- // RequestConfig 向控制面(Gateway)请求某 kind 当前激活配置(chat/embedding)。 diff --git a/sundynix-shared/contract/task.go b/sundynix-shared/contract/task.go index 73c7614..8965a90 100644 --- a/sundynix-shared/contract/task.go +++ b/sundynix-shared/contract/task.go @@ -29,6 +29,30 @@ const ( // request-reply 心跳主题让控制面(管理端「服务状态」)能判定它在不在线。 SubjectHealthDispatcher = "sundynix.health.dispatcher" + // 任务生命周期状态回写:dispatcher 开跑/跑完/出错经此主题广播,网关订阅落 PG 并推 UI。 + // core NATS pub-sub(状态是幂等覆盖,丢一条由下一条纠正,无需持久化)。 + SubjectTaskStatus = "sundynix.tasks.status" +) + +// 任务生命周期状态机:submitted(网关建任务)→ running(dispatcher 开跑) +// → done / failed / timeout(dispatcher 收尾)。 +const ( + TaskSubmitted = "submitted" + TaskRunning = "running" + TaskDone = "done" + TaskFailed = "failed" + TaskTimeout = "timeout" +) + +// TaskStatusEvent 是一次任务状态流转事件(经 SubjectTaskStatus 回流给网关)。 +type TaskStatusEvent struct { + TaskID string `json:"task_id"` + Status string `json:"status"` // running / done / failed / timeout + Detail string `json:"detail,omitempty"` // 失败原因等 + TS int64 `json:"ts"` // unix 毫秒 +} + +const ( // MetaUserID 是 Task.Meta 中承载已登录用户标识的键(用于偏好记忆召回)。 MetaUserID = "user_id" // MetaSessionID 是 Task.Meta 中承载会话标识的键(用于短期多轮历史)。 From 2ee16d1f992d460e3ff8ae5be34c415906655e38 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 23 Jun 2026 10:42:50 +0800 Subject: [PATCH 02/15] =?UTF-8?q?feat(dispatcher):=20Eino=20=E9=87=87?= =?UTF-8?q?=E7=BA=B3=20Phase=20C=20=E2=80=94=E2=80=94=20=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E4=B8=BB=E6=B5=81=E7=A8=8B=E8=B7=91=20compose.Graph=20+=20call?= =?UTF-8?q?backs=20=E5=BD=92=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按"并存 + 等价回归"策略,对话主流程可跑在 Eino compose.Graph 上: - compose_graph.go:runConversation 按 EINO_COMPOSE 灰度开关分流; runComposeConversation 建图 START→ChatModel→END,Compile→Stream 回流 token; 模型未就绪/编译失败降级回 runAgent。默认关,graph.go 仍是默认且权威。 - compose_callbacks.go:composeTracer 用 utils/callbacks 把 ChatModel/Tool 的 start/end/error 桥到 ExecEvent(可观测归一,不再各处手写 emit)。 - LLM 接口 + Pool 增 ChatModel();fakeLLM 加 cm 字段 + stub Eino 模型。 - 测试:compose 图编译运行 / compose 对话流式 / 开关关→走 runAgent。 顺带修真 bug:SubjectTaskStatus 原 sundynix.tasks.status 落在任务流通配 sundynix.tasks.> 内 → 状态事件被当成"幽灵任务"自我放大(实测污染 2300+ 条)。 挪到 sundynix.status.task + dispatcher 加空任务护栏。 验收:make test-go 全绿;live compose 路径 54 字答复 eval 1.00、默认路径 eval 1.00、幽灵任务 0 复发。剩余 branch/map/render 等节点逐步迁移后 graph.go 退役。 Co-Authored-By: Claude Opus 4.8 (1M context) --- EINO_ADOPTION.md | 14 ++- .../internal/eino/compose_callbacks.go | 68 ++++++++++++++ .../internal/eino/compose_graph.go | 93 +++++++++++++++++++ .../internal/eino/compose_graph_test.go | 89 ++++++++++++++++++ sundynix-dispatcher/internal/eino/graph.go | 6 +- .../internal/eino/integration_test.go | 4 + .../internal/eino/orchestrator.go | 7 ++ sundynix-dispatcher/internal/llm/pool.go | 3 + sundynix-shared/contract/task.go | 3 +- 9 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 sundynix-dispatcher/internal/eino/compose_callbacks.go create mode 100644 sundynix-dispatcher/internal/eino/compose_graph.go create mode 100644 sundynix-dispatcher/internal/eino/compose_graph_test.go diff --git a/EINO_ADOPTION.md b/EINO_ADOPTION.md index 3acb352..585282d 100644 --- a/EINO_ADOPTION.md +++ b/EINO_ADOPTION.md @@ -9,7 +9,7 @@ - [x] **Phase A · 地基**:`llm.Pool` 换 Eino ChatModel 组件(commit d84b1ec,验收通过) - [x] **Phase B · 质变**:MCP 工具→`InvokableTool` + ReAct agent(模型自主调工具,验收 7/7 命中) -- [ ] **Phase C · 编排归一**:`Flow→compose.Graph` + callbacks 桥接 +- [~] **Phase C · 编排归一**:✅ 对话主流程跑 `compose.Graph`(EINO_COMPOSE 灰度开关,默认关)+ callbacks→ExecEvent 归一 / ⬜ branch·map·render 等节点逐步迁移(并存+等价回归) - [~] **Phase D · 状态化执行**:✅ 任务生命周期 FSM(已完成)/ ⬜ HITL 中断恢复 / ⬜ 多智能体(按场景) --- @@ -117,7 +117,17 @@ github.com/cloudwego/eino-ext/... # ⚠️ 官方组件实现(open --- -## Phase C · 编排归一:迁到 compose.Graph 〔P2〕 +## Phase C · 编排归一:迁到 compose.Graph 〔P2〕🟡 进行中(核心已落地,并存灰度) + +> 落地(并存+等价回归策略):对话主流程已可跑在 `compose.Graph` 上—— +> - `compose_graph.go`:`runConversation` 按 `EINO_COMPOSE` 开关分流;`runComposeConversation` 建图 `START→ChatModel→END`、`Compile`→`Stream`,token 回流;模型未就绪/编译失败降级回 `runAgent`。**默认关,graph.go 仍是默认且权威。** +> - `compose_callbacks.go`:`composeTracer` 用 `utils/callbacks` 把 ChatModel/Tool 的 start/end/error 桥到 ExecEvent(可观测归一)。 +> - 测试:compose 图编译+运行、compose 对话流式回流、开关关→走 runAgent,三个单测;live 实测 compose 路径出 54 字答复 + eval 1.00,默认路径 eval 1.00。 +> - **顺带修真 bug**:`SubjectTaskStatus` 原为 `sundynix.tasks.status`,落在任务流捕获通配 `sundynix.tasks.>` 内 → 状态事件被当成"幽灵任务"自我放大(实测污染 2300+ 条)。已挪到 `sundynix.status.task` + dispatcher 加空任务护栏。 +> +> 剩余:branch / map / render / retriever / prompt 等节点逐步迁 compose(同并存+等价回归),对齐后 graph.go 退役。 + + **目标**:自研解释器退役,DSL 图编译为 `compose.Graph`,吃到流式 reduce/branch、类型化边、自动并发、callbacks。 diff --git a/sundynix-dispatcher/internal/eino/compose_callbacks.go b/sundynix-dispatcher/internal/eino/compose_callbacks.go new file mode 100644 index 0000000..3466255 --- /dev/null +++ b/sundynix-dispatcher/internal/eino/compose_callbacks.go @@ -0,0 +1,68 @@ +package eino + +import ( + "context" + "fmt" + + "github.com/cloudwego/eino/callbacks" + "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/schema" + ucallbacks "github.com/cloudwego/eino/utils/callbacks" +) + +// composeTracer 把 Eino compose 运行时的回调(ChatModel / Tool 的 start/end/error) +// 翻译成我们现有的 ExecEvent 轨迹——可观测"归一":用框架原生回调,而非各处手写 emit。 +// node 为该次运行在"运行·观测"里的归属节点 id(如 agent:xxx)。 +func composeTracer(tr *execTracer, node string) callbacks.Handler { + return ucallbacks.NewHandlerHelper(). + ChatModel(&ucallbacks.ModelCallbackHandler{ + OnStart: func(ctx context.Context, _ *callbacks.RunInfo, in *model.CallbackInput) context.Context { + tr.emit(node, "model", "start", "compose·ChatModel", inputMsgsPreview(in), 0) + return ctx + }, + OnEnd: func(ctx context.Context, _ *callbacks.RunInfo, out *model.CallbackOutput) context.Context { + detail := "" + if out != nil && out.Message != nil { + detail = truncate(out.Message.Content, 120) + } + tr.emit(node, "model", "end", "compose·ChatModel", detail, 0) + return ctx + }, + OnEndWithStreamOutput: func(ctx context.Context, _ *callbacks.RunInfo, out *schema.StreamReader[*model.CallbackOutput]) context.Context { + out.Close() // 仅观测:正文经主输出流消费,这里只标记结束 + tr.emit(node, "model", "end", "compose·ChatModel", "流式完成", 0) + return ctx + }, + OnError: func(ctx context.Context, _ *callbacks.RunInfo, err error) context.Context { + tr.emit(node, "model", "error", "compose·ChatModel", err.Error(), 0) + return ctx + }, + }). + Tool(&ucallbacks.ToolCallbackHandler{ + OnStart: func(ctx context.Context, _ *callbacks.RunInfo, in *tool.CallbackInput) context.Context { + if in != nil { + tr.emit(node, "tool", "start", "compose·工具", truncate(in.ArgumentsInJSON, 120), 0) + } + return ctx + }, + OnEnd: func(ctx context.Context, _ *callbacks.RunInfo, out *tool.CallbackOutput) context.Context { + if out != nil { + tr.emit(node, "tool", "end", "compose·工具", truncate(out.Response, 160), 0) + } + return ctx + }, + OnError: func(ctx context.Context, _ *callbacks.RunInfo, err error) context.Context { + tr.emit(node, "tool", "error", "compose·工具", err.Error(), 0) + return ctx + }, + }). + Handler() +} + +func inputMsgsPreview(in *model.CallbackInput) string { + if in == nil { + return "" + } + return fmt.Sprintf("%d 条消息", len(in.Messages)) +} diff --git a/sundynix-dispatcher/internal/eino/compose_graph.go b/sundynix-dispatcher/internal/eino/compose_graph.go new file mode 100644 index 0000000..7b14d40 --- /dev/null +++ b/sundynix-dispatcher/internal/eino/compose_graph.go @@ -0,0 +1,93 @@ +package eino + +import ( + "context" + "fmt" + "io" + "os" + "time" + + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/schema" + + "github.com/sundynix/sundynix-dispatcher/internal/harness" +) + +// composeEnabled 报告是否启用 compose.Graph 编排路径(Phase C 灰度开关,默认关 → 走自研 graph.go)。 +// 并存策略:EINO_COMPOSE=1 时对话主流程改走 Eino compose 运行时,行为对齐后再逐步退役 graph.go。 +func composeEnabled() bool { return os.Getenv("EINO_COMPOSE") == "1" } + +// runConversation 是对话/模型节点的统一入口:按灰度开关选 compose.Graph 或自研 runAgent。 +// 二者对外行为一致(据黑板拼消息 → 流式回流 token → 累计成稿),便于等价回归。 +func (o *Orchestrator) runConversation(ctx context.Context, taskID string, b *board, system string, tr *execTracer, node string) { + if composeEnabled() { + o.runComposeConversation(ctx, taskID, b, system, tr, node) + return + } + o.runAgent(ctx, taskID, b, system, tr, node) +} + +// runComposeConversation 用 Eino compose.Graph 跑对话主流程: +// START → ChatModel 节点 → END,编译为 Runnable 后流式执行;可观测经 callbacks 桥到 ExecEvent。 +// 模型未就绪 / 编译失败时降级回自研 runAgent,保证不回归。 +func (o *Orchestrator) runComposeConversation(ctx context.Context, taskID string, b *board, system string, tr *execTracer, node string) { + cm := o.pool.ChatModel() + if cm == nil { + o.runAgent(ctx, taskID, b, system, tr, node) // 无模型 → 走自研路径的降级桩 + return + } + + g := compose.NewGraph[[]*schema.Message, *schema.Message]() + if err := g.AddChatModelNode("model", cm); err != nil { + tr.info(node, "system", "compose 降级", "建图失败,退回自研路径:"+err.Error()) + o.runAgent(ctx, taskID, b, system, tr, node) + return + } + _ = g.AddEdge(compose.START, "model") + _ = g.AddEdge("model", compose.END) + r, err := g.Compile(ctx) + if err != nil { + tr.info(node, "system", "compose 降级", "编译失败,退回自研路径:"+err.Error()) + o.runAgent(ctx, taskID, b, system, tr, node) + return + } + + rc := &RunCtx{ + UserID: b.uid, SessionID: b.sid, + System: firstNonEmpty(system, defaultAgentSystem), + Query: b.query, + Profile: b.profile, + History: b.history, + ToolOut: append(append([]string{}, b.toolOut...), b.refs...), + } + msgs, _ := buildMessages(ctx, rc) + + t0 := time.Now() + // ChatModel 的 start/end 由 composeTracer(callbacks)落轨迹,这里不再手写 emit(归一)。 + sr, err := r.Stream(ctx, msgs, compose.WithCallbacks(composeTracer(tr, node))) + if err != nil { + tr.emit(node, "model", "error", "compose 图执行", err.Error(), time.Since(t0).Milliseconds()) + return + } + defer sr.Close() + + chunks := 0 + for { + chunk, rerr := sr.Recv() + if rerr == io.EOF { + break + } + if rerr != nil { + tr.emit(node, "model", "error", "compose 图执行", rerr.Error(), time.Since(t0).Milliseconds()) + return + } + if chunk.Content == "" { + continue + } + safe, _ := harness.RedactSecrets(chunk.Content) // 输出护栏:逐片脱敏 + _ = o.sink.PublishToken(taskID, []byte(safe)) + b.answer += safe + chunks++ + } + tr.info(node, "system", "compose 图", fmt.Sprintf("%d 段输出 / %d 字(Eino compose 运行时)", chunks, len([]rune(b.answer)))) +} diff --git a/sundynix-dispatcher/internal/eino/compose_graph_test.go b/sundynix-dispatcher/internal/eino/compose_graph_test.go new file mode 100644 index 0000000..f5c5c6a --- /dev/null +++ b/sundynix-dispatcher/internal/eino/compose_graph_test.go @@ -0,0 +1,89 @@ +package eino + +import ( + "context" + "strings" + "testing" + + "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/schema" + + "github.com/sundynix/sundynix-dispatcher/internal/harness" + "github.com/sundynix/sundynix-dispatcher/internal/llm" +) + +// stubModel 是实现 Eino model.BaseChatModel 的测试桩,固定回一段文本(确定性)。 +type stubModel struct{ reply string } + +func (s *stubModel) Generate(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) { + return schema.AssistantMessage(s.reply, nil), nil +} + +func (s *stubModel) Stream(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) { + sr, sw := schema.Pipe[*schema.Message](1) + go func() { + sw.Send(schema.AssistantMessage(s.reply, nil), nil) + sw.Close() + }() + return sr, nil +} + +// TestComposeGraphRuns 直接验证 compose.Graph(START→ChatModel→END)能编译并流式产出。 +func TestComposeGraphRuns(t *testing.T) { + g := compose.NewGraph[[]*schema.Message, *schema.Message]() + if err := g.AddChatModelNode("model", &stubModel{reply: "你好世界"}); err != nil { + t.Fatal(err) + } + _ = g.AddEdge(compose.START, "model") + _ = g.AddEdge("model", compose.END) + r, err := g.Compile(context.Background()) + if err != nil { + t.Fatalf("compile: %v", err) + } + out, err := r.Invoke(context.Background(), []*schema.Message{schema.UserMessage("hi")}) + if err != nil { + t.Fatalf("invoke: %v", err) + } + if out.Content != "你好世界" { + t.Fatalf("got %q", out.Content) + } +} + +// TestComposeConversationEquivalent 验证 EINO_COMPOSE=1 时对话主流程走 compose 路径, +// 把模型输出流式回流到 sink 并累计成稿——与自研 runAgent 行为等价(都逐片转发模型输出)。 +func TestComposeConversationEquivalent(t *testing.T) { + t.Setenv("EINO_COMPOSE", "1") + fs := &fakeSink{} + o := &Orchestrator{ + pool: &fakeLLM{ready: true, cm: &stubModel{reply: "我是 compose 路径的回答"}}, + breaker: harness.NewCircuitBreaker(), + sink: fs, + } + b := &board{query: "你好"} + tr := &execTracer{} // sink 为 nil → 轨迹发射空操作 + o.runConversation(context.Background(), "task_compose", b, "", tr, "agent") + + if !strings.Contains(b.answer, "compose 路径") { + t.Fatalf("成稿未含模型输出: %q", b.answer) + } + if !strings.Contains(fs.text(), "compose 路径") { + t.Fatalf("sink 未收到流式 token: %q", fs.text()) + } +} + +// TestComposeDisabledUsesRunAgent 验证开关关闭时仍走自研 runAgent(默认行为不变)。 +func TestComposeDisabledUsesRunAgent(t *testing.T) { + t.Setenv("EINO_COMPOSE", "0") + fs := &fakeSink{} + o := &Orchestrator{ + pool: &fakeLLM{ready: true, stream: func([]llm.ChatMessage) string { return "自研路径回答" }}, + breaker: harness.NewCircuitBreaker(), + sink: fs, + } + b := &board{query: "你好"} + o.runConversation(context.Background(), "task_legacy", b, "", &execTracer{}, "agent") + if !strings.Contains(b.answer, "自研路径") { + t.Fatalf("未走自研路径: %q", b.answer) + } +} diff --git a/sundynix-dispatcher/internal/eino/graph.go b/sundynix-dispatcher/internal/eino/graph.go index 5b95d86..aa52d51 100644 --- a/sundynix-dispatcher/internal/eino/graph.go +++ b/sundynix-dispatcher/internal/eino/graph.go @@ -50,7 +50,7 @@ func (o *Orchestrator) runGraph(ctx context.Context, t *contract.Task, tr *execT tr.info("task", "system", "无结构化图", "按单轮对话执行") b.profile = o.fetchMemory(ctx, b.uid, b.query) b.history = o.fetchHistory(ctx, b.sid) - o.runAgent(ctx, t.ID, b, plan.System, tr, "agent") + o.runConversation(ctx, t.ID, b, plan.System, tr, "agent") return b.answer, nil } @@ -123,7 +123,7 @@ func (o *Orchestrator) runGraph(ctx context.Context, t *contract.Task, tr *execT if cbool(n.Config, "autonomous") { // 开启自主工具 → ReAct(模型自己选工具) o.runReactAgent(ctx, t.ID, b, sys, n, tr, "agent:"+n.ID) } else { - o.runAgent(ctx, t.ID, b, sys, tr, "agent:"+n.ID) + o.runConversation(ctx, t.ID, b, sys, tr, "agent:"+n.ID) } case "aggregate": merged := aggregate(cstr(n.Config, "strategy"), append(append([]string{}, b.refs...), b.toolOut...)) @@ -147,7 +147,7 @@ func (o *Orchestrator) runGraph(ctx context.Context, t *contract.Task, tr *execT // 图里无 agent 节点(纯工具/检索图)也要出一段模型答复,否则没有输出。 if b.answer == "" { - o.runAgent(ctx, t.ID, b, plan.System, tr, "agent") + o.runConversation(ctx, t.ID, b, plan.System, tr, "agent") } return b.answer, nil } diff --git a/sundynix-dispatcher/internal/eino/integration_test.go b/sundynix-dispatcher/internal/eino/integration_test.go index 50da8fc..b3577c6 100644 --- a/sundynix-dispatcher/internal/eino/integration_test.go +++ b/sundynix-dispatcher/internal/eino/integration_test.go @@ -20,6 +20,7 @@ type fakeLLM struct { ready bool stream func(msgs []llm.ChatMessage) string // ChatStream 要回流的整段文本 chat func(msgs []llm.ChatMessage) (string, error) // Chat 返回 + cm model.BaseChatModel // compose 路径用的 Eino 模型(可为 nil) } func (f *fakeLLM) Ready() bool { return f.ready } @@ -43,6 +44,9 @@ func (f *fakeLLM) Chat(_ context.Context, msgs []llm.ChatMessage) (string, error // ToolCallingModel:假模型不支持函数调用 → ReAct 路径会降级回普通对话。 func (f *fakeLLM) ToolCallingModel() model.ToolCallingChatModel { return nil } +// ChatModel:compose 路径用;nil 时 runComposeConversation 降级回 runAgent。 +func (f *fakeLLM) ChatModel() model.BaseChatModel { return f.cm } + type fakeSink struct { mu sync.Mutex tokens []string diff --git a/sundynix-dispatcher/internal/eino/orchestrator.go b/sundynix-dispatcher/internal/eino/orchestrator.go index 1a5428d..93aa78f 100644 --- a/sundynix-dispatcher/internal/eino/orchestrator.go +++ b/sundynix-dispatcher/internal/eino/orchestrator.go @@ -43,6 +43,8 @@ type LLM interface { Chat(ctx context.Context, msgs []llm.ChatMessage) (string, error) // ToolCallingModel 返回支持函数调用的模型(ReAct agent 用);不支持则返回 nil。 ToolCallingModel() model.ToolCallingChatModel + // ChatModel 返回 Eino ChatModel 组件(compose.Graph 编排用);未就绪则 nil。 + ChatModel() model.BaseChatModel } // 工具调用超时;超时即降级(不带工具上下文继续推理)。 @@ -96,6 +98,11 @@ func (o *Orchestrator) finishStatus(taskID string, err error) { // Handle 消费一个任务:按 DSL 编译 Eino 图并执行,把 Token 流回流到 sundynix.streams.。 func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error { + // 护栏:丢弃空任务(无 id),避免误投/历史脏数据被当真任务处理并触发状态回写放大。 + if t.ID == "" { + log.Printf("[eino] 跳过空任务(无 id)") + return nil + } tr := o.tracer(t.ID) defer tr.done() diff --git a/sundynix-dispatcher/internal/llm/pool.go b/sundynix-dispatcher/internal/llm/pool.go index 0b40538..8901603 100644 --- a/sundynix-dispatcher/internal/llm/pool.go +++ b/sundynix-dispatcher/internal/llm/pool.go @@ -76,6 +76,9 @@ func (p *Pool) model() model.BaseChatModel { // Ready 报告是否已配置可用后端(且 ChatModel 构建成功)。 func (p *Pool) Ready() bool { return p.model() != nil } +// ChatModel 返回当前 Eino ChatModel 组件(用于 compose.Graph 编排);未就绪则 nil。 +func (p *Pool) ChatModel() model.BaseChatModel { return p.model() } + // ToolCallingModel 返回支持函数调用的模型(用于 ReAct agent);未就绪 / 不支持则 nil。 func (p *Pool) ToolCallingModel() model.ToolCallingChatModel { if tcm, ok := p.model().(model.ToolCallingChatModel); ok { diff --git a/sundynix-shared/contract/task.go b/sundynix-shared/contract/task.go index 8965a90..fa20099 100644 --- a/sundynix-shared/contract/task.go +++ b/sundynix-shared/contract/task.go @@ -31,7 +31,8 @@ const ( // 任务生命周期状态回写:dispatcher 开跑/跑完/出错经此主题广播,网关订阅落 PG 并推 UI。 // core NATS pub-sub(状态是幂等覆盖,丢一条由下一条纠正,无需持久化)。 - SubjectTaskStatus = "sundynix.tasks.status" + // 注意:必须在 sundynix.tasks.> 之外,否则会被任务流捕获成"幽灵任务"自我放大。 + SubjectTaskStatus = "sundynix.status.task" ) // 任务生命周期状态机:submitted(网关建任务)→ running(dispatcher 开跑) From 2e927eca0af9c249b5c8e8b6b0baeaac64bc9d9d Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 23 Jun 2026 11:02:32 +0800 Subject: [PATCH 03/15] =?UTF-8?q?feat(dispatcher):=20Eino=20=E9=87=87?= =?UTF-8?q?=E7=BA=B3=20Phase=20C=20=E5=AE=8C=E6=88=90=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E5=85=A8=E5=9B=BE=20DSL=E2=86=92compose.Graph=20=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把整张 DSL 图编译为 Eino compose.Graph 执行(编排归一): - compose_compiler.go:每节点=Lambda,节点体复用 execDSLNode(全节点类型); 黑板进 compose 本地状态(WithGenLocalState + ProcessState);branch 走 AddBranch + 状态感知条件(复用 branchNode);边载荷 flowSignal(注册 no-op 合并支持 fan-in,真实数据全走黑板)。 - WithNodeTriggerMode(AllPredecessor) DAG 模式:无依赖节点并行调度(效率)。 - Handle→executeGraph 按 EINO_COMPOSE 开关选 compose/graph.go;compose 编译 失败自动降级回 graph.go(安全网)。默认关,graph.go 仍权威。 等价回归:线性图 + 分支图经解释器与 compose 两路径产出逐字一致(单测); live 多节点分支图 compose 路径 2800 字答复 eval 1.00、FSM done、0 幽灵; make test-go 全绿。 过渡期 soak 后翻默认到 compose、退役 graph.go。性能后续:编译图按 DSL-hash 缓存。 Co-Authored-By: Claude Opus 4.8 (1M context) --- EINO_ADOPTION.md | 15 +- .../internal/eino/compose_compiler.go | 217 ++++++++++++++++++ .../internal/eino/compose_compiler_test.go | 76 ++++++ .../internal/eino/orchestrator.go | 4 +- 4 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 sundynix-dispatcher/internal/eino/compose_compiler.go create mode 100644 sundynix-dispatcher/internal/eino/compose_compiler_test.go diff --git a/EINO_ADOPTION.md b/EINO_ADOPTION.md index 585282d..c199335 100644 --- a/EINO_ADOPTION.md +++ b/EINO_ADOPTION.md @@ -9,7 +9,7 @@ - [x] **Phase A · 地基**:`llm.Pool` 换 Eino ChatModel 组件(commit d84b1ec,验收通过) - [x] **Phase B · 质变**:MCP 工具→`InvokableTool` + ReAct agent(模型自主调工具,验收 7/7 命中) -- [~] **Phase C · 编排归一**:✅ 对话主流程跑 `compose.Graph`(EINO_COMPOSE 灰度开关,默认关)+ callbacks→ExecEvent 归一 / ⬜ branch·map·render 等节点逐步迁移(并存+等价回归) +- [x] **Phase C · 编排归一**:✅ 全图 `DSL→compose.Graph` 编译器(全节点 + branch + DAG 并行调度)+ callbacks→ExecEvent 归一,等价回归通过(EINO_COMPOSE 灰度开关,默认关;过渡期后 graph.go 退役) - [~] **Phase D · 状态化执行**:✅ 任务生命周期 FSM(已完成)/ ⬜ HITL 中断恢复 / ⬜ 多智能体(按场景) --- @@ -117,7 +117,18 @@ github.com/cloudwego/eino-ext/... # ⚠️ 官方组件实现(open --- -## Phase C · 编排归一:迁到 compose.Graph 〔P2〕🟡 进行中(核心已落地,并存灰度) +## Phase C · 编排归一:迁到 compose.Graph 〔P2〕✅ 已完成(并存灰度,等价回归通过) + +> 全图编译器:`compose_compiler.go` 把整张 DSL 图编译为 `compose.Graph`—— +> - 每个节点 = 一个 Lambda,节点体复用现有逻辑(`execDSLNode`:input/memory/retriever/tool/agent/aggregate/render/map/output),黑板进 compose 本地状态(`WithGenLocalState` + `ProcessState`)。 +> - branch = `AddBranch` + 状态感知条件(复用 `branchNode` 选路);边载荷用空 `flowSignal`(注册 no-op 合并支持 fan-in),真实数据全走黑板。 +> - `WithNodeTriggerMode(AllPredecessor)` DAG 模式:无依赖节点并行调度(效率)。 +> - `Handle → executeGraph` 按 `EINO_COMPOSE` 开关选 compose / graph.go;compose 编译失败自动降级回 graph.go(安全网)。 +> - **等价回归**:线性图、分支图经解释器与 compose 两路径产出逐字一致(单测);live 多节点分支图 compose 路径 2800 字答复 eval 1.00、FSM done、0 幽灵。 +> +> 待过渡期 soak 后把默认翻到 compose、退役 graph.go。**性能后续**:编译图按 DSL-hash 缓存(当前每任务编译一次)。 + + > 落地(并存+等价回归策略):对话主流程已可跑在 `compose.Graph` 上—— > - `compose_graph.go`:`runConversation` 按 `EINO_COMPOSE` 开关分流;`runComposeConversation` 建图 `START→ChatModel→END`、`Compile`→`Stream`,token 回流;模型未就绪/编译失败降级回 `runAgent`。**默认关,graph.go 仍是默认且权威。** diff --git a/sundynix-dispatcher/internal/eino/compose_compiler.go b/sundynix-dispatcher/internal/eino/compose_compiler.go new file mode 100644 index 0000000..d0dc0d7 --- /dev/null +++ b/sundynix-dispatcher/internal/eino/compose_compiler.go @@ -0,0 +1,217 @@ +package eino + +import ( + "context" + "fmt" + "sync" + + "github.com/cloudwego/eino/compose" + + "github.com/sundynix/sundynix-dispatcher/internal/dsl" + "github.com/sundynix/sundynix-shared/contract" +) + +// flowSignal 是 compose 编排图的边载荷(占位):真实数据全走 compose 本地状态(*board), +// 边只传"该走了"的信号。注册 no-op 合并以支持 fan-in(多分支汇聚到一个节点)。 +type flowSignal struct{} + +var registerMergeOnce sync.Once + +func registerFlowMerge() { + registerMergeOnce.Do(func() { + compose.RegisterValuesMergeFunc(func([]flowSignal) (flowSignal, error) { return flowSignal{}, nil }) + }) +} + +// executeGraph 按灰度开关选编排实现:compose.Graph(Phase C)或自研 graph.go(默认/权威)。 +func (o *Orchestrator) executeGraph(ctx context.Context, t *contract.Task, tr *execTracer) (string, error) { + if composeEnabled() { + return o.runComposeGraph(ctx, t, tr) + } + return o.runGraph(ctx, t, tr) +} + +// runComposeGraph 把 DSL 图编译为 Eino compose.Graph 并执行(Phase C 编排归一): +// 节点体复用现有 execDSLNode;黑板进 compose 本地状态;branch 走 AddBranch; +// DAG 触发模式让无依赖节点并行调度(效率)。编译失败即降级回自研 graph.go(安全网)。 +func (o *Orchestrator) runComposeGraph(ctx context.Context, t *contract.Task, tr *execTracer) (string, error) { + registerFlowMerge() + flow, ferr := dsl.Parse(t.Graph) + plan := dsl.Compile(t.Graph) + b := &board{ + uid: meta(t, contract.MetaUserID), + sid: meta(t, contract.MetaSessionID), + query: plan.Query, + } + + // 无图/空图:退化为 compose 单轮对话。 + if ferr != nil || flow == nil || len(flow.Nodes) == 0 { + tr.info("task", "system", "无结构化图", "按单轮对话执行(compose)") + b.profile = o.fetchMemory(ctx, b.uid, b.query) + b.history = o.fetchHistory(ctx, b.sid) + o.runComposeConversation(ctx, t.ID, b, plan.System, tr, "agent") + return b.answer, nil + } + + // 邻接 + 入度(只认两端都存在的边)。 + nodeByID := make(map[string]dsl.Node, len(flow.Nodes)) + outE := make(map[string][]dsl.Edge) + indeg := make(map[string]int, len(flow.Nodes)) + for _, n := range flow.Nodes { + nodeByID[n.ID] = n + indeg[n.ID] = 0 + } + for _, e := range flow.Edges { + if _, ok := nodeByID[e.Source]; !ok { + continue + } + if _, ok := nodeByID[e.Target]; !ok { + continue + } + outE[e.Source] = append(outE[e.Source], e) + indeg[e.Target]++ + } + + // 图里无 memory 节点 → 沿用默认:注入画像+历史(与 graph.go 对齐,避免回归)。 + hasMemory := false + for _, n := range flow.Nodes { + if n.Kind == "memory" { + hasMemory = true + break + } + } + if !hasMemory { + b.profile = o.fetchMemory(ctx, b.uid, b.query) + b.history = o.fetchHistory(ctx, b.sid) + } + + // 建 compose 图:黑板进本地状态(GenLocalState 闭包持有本任务的 b)。 + g := compose.NewGraph[flowSignal, flowSignal]( + compose.WithGenLocalState(func(context.Context) *board { return b }), + ) + key := func(id string) string { return "n_" + id } // 节点 key 加前缀,避开 START/END 保留字 + + // 1) 加节点(branch 为 passthrough,路由交给 AddBranch)。 + for _, n := range flow.Nodes { + node := n + if node.Kind == "branch" { + _ = g.AddLambdaNode(key(node.ID), compose.InvokableLambda( + func(context.Context, flowSignal) (flowSignal, error) { return flowSignal{}, nil })) + continue + } + _ = g.AddLambdaNode(key(node.ID), compose.InvokableLambda( + func(c context.Context, _ flowSignal) (flowSignal, error) { + perr := compose.ProcessState(c, func(sc context.Context, bd *board) error { + o.execDSLNode(sc, t, node, bd, plan, tr) + return nil + }) + return flowSignal{}, perr + })) + } + + // 2) 连边。branch 用 AddBranch(条件读 board 选下游);其余直连;终端节点连 END。 + for _, n := range flow.Nodes { + node := n + outs := outE[node.ID] + if node.Kind == "branch" { + endNodes := map[string]bool{compose.END: true} + for _, e := range outs { + if _, ok := nodeByID[e.Target]; ok { + endNodes[key(e.Target)] = true + } + } + brn := node + cond := func(c context.Context, _ flowSignal) (map[string]bool, error) { + chosen := map[string]bool{} + _ = compose.ProcessState(c, func(sc context.Context, bd *board) error { + for _, tgt := range o.branchNode(brn, bd, outE[brn.ID], nodeByID, tr) { + chosen[key(tgt)] = true + } + return nil + }) + if len(chosen) == 0 { + chosen[compose.END] = true // 没选中任何下游 → 收口到 END,避免悬挂 + } + return chosen, nil + } + _ = g.AddBranch(key(node.ID), compose.NewGraphMultiBranch(cond, endNodes)) + continue + } + if len(outs) == 0 { + _ = g.AddEdge(key(node.ID), compose.END) + continue + } + for _, e := range outs { + if _, ok := nodeByID[e.Target]; ok { + _ = g.AddEdge(key(node.ID), key(e.Target)) + } + } + } + + // 3) 入口节点(入度 0)连 START。 + for _, n := range flow.Nodes { + if indeg[n.ID] == 0 { + _ = g.AddEdge(compose.START, key(n.ID)) + } + } + + // 4) 编译(DAG 模式:无依赖节点并行调度)。编译失败 → 降级回自研 graph.go(安全网)。 + r, cerr := g.Compile(ctx, compose.WithNodeTriggerMode(compose.AllPredecessor)) + if cerr != nil { + tr.info("task", "system", "compose 编译失败", "退回自研 graph.go:"+cerr.Error()) + return o.runGraph(ctx, t, tr) + } + if _, ierr := r.Invoke(ctx, flowSignal{}); ierr != nil { + tr.info("task", "system", "compose 执行告警", ierr.Error()) // 副作用已落 board;下方按需补一段答复 + } + + // 图里无 agent 节点(纯工具/检索图)也要出一段答复。 + if b.answer == "" { + o.runComposeConversation(ctx, t.ID, b, plan.System, tr, "agent") + } + return b.answer, nil +} + +// execDSLNode 执行一个非 branch 的 DSL 节点(compose 编译器用;节点体与 graph.go 一致, +// 区别仅 agent 直接走 runAgent/runReactAgent——compose 已是编排层,不再二次套 compose)。 +func (o *Orchestrator) execDSLNode(ctx context.Context, t *contract.Task, n dsl.Node, b *board, plan dsl.Plan, tr *execTracer) { + switch n.Kind { + case "input": + if txt := cstr(n.Config, "text"); txt != "" { + b.query = txt + } + tr.info("input:"+n.ID, "system", labelOf(n, "输入"), truncate(b.query, 80)) + case "memory": + if cbool(n.Config, "profile") { + b.profile = o.fetchMemory(ctx, b.uid, b.query) + } + if cbool(n.Config, "history") { + b.history = o.fetchHistory(ctx, b.sid) + } + tr.info("memory:"+n.ID, "memory", labelOf(n, "记忆"), + fmt.Sprintf("画像 %d 字 · 历史 %d 条", len([]rune(b.profile)), len(b.history))) + case "retriever": + o.retrieverNode(ctx, n, b, tr) + case "tool": + o.execToolNode(ctx, t.ID, n, b, tr) + case "agent": + sys := firstNonEmpty(cstr(n.Config, "system"), plan.System) + if cbool(n.Config, "autonomous") { + o.runReactAgent(ctx, t.ID, b, sys, n, tr, "agent:"+n.ID) + } else { + o.runAgent(ctx, t.ID, b, sys, tr, "agent:"+n.ID) + } + case "aggregate": + merged := aggregate(cstr(n.Config, "strategy"), append(append([]string{}, b.refs...), b.toolOut...)) + b.refs, b.toolOut = merged, nil + tr.info("aggregate:"+n.ID, "system", labelOf(n, "汇聚"), "策略:"+firstNonEmpty(cstr(n.Config, "strategy"), "拼接")) + case "render": + o.renderNode(ctx, t.ID, n, b, tr) + case "map": + o.mapNode(ctx, t.ID, n, b, tr) + case "output": + tr.info("output:"+n.ID, "system", labelOf(n, "输出"), "目标:"+firstNonEmpty(cstr(n.Config, "target"), "屏幕")) + default: + tr.info(n.Kind+":"+n.ID, "system", labelOf(n, n.Kind), "未识别节点,跳过") + } +} diff --git a/sundynix-dispatcher/internal/eino/compose_compiler_test.go b/sundynix-dispatcher/internal/eino/compose_compiler_test.go new file mode 100644 index 0000000..1f39c7a --- /dev/null +++ b/sundynix-dispatcher/internal/eino/compose_compiler_test.go @@ -0,0 +1,76 @@ +package eino + +import ( + "context" + "testing" + + "github.com/sundynix/sundynix-dispatcher/internal/harness" + "github.com/sundynix/sundynix-dispatcher/internal/llm" + "github.com/sundynix/sundynix-shared/contract" +) + +// echoLLM 回显最后一条 user 消息内容(确定性),便于两条执行路径逐字对比。 +func echoLLM() *fakeLLM { + return &fakeLLM{ + ready: true, + stream: func(m []llm.ChatMessage) string { + for i := len(m) - 1; i >= 0; i-- { + if m[i].Role == "user" { + return "ANS:" + m[i].Content + } + } + return "ANS:" + }, + } +} + +func runBoth(t *testing.T, graph string) (interp, comp string) { + t.Helper() + task := &contract.Task{ID: "t_eq", Graph: []byte(graph)} + + o1 := &Orchestrator{pool: echoLLM(), breaker: harness.NewCircuitBreaker(), sink: &fakeSink{}} + a1, err := o1.runGraph(context.Background(), task, &execTracer{}) + if err != nil { + t.Fatalf("runGraph: %v", err) + } + o2 := &Orchestrator{pool: echoLLM(), breaker: harness.NewCircuitBreaker(), sink: &fakeSink{}} + a2, err := o2.runComposeGraph(context.Background(), task, &execTracer{}) + if err != nil { + t.Fatalf("runComposeGraph: %v", err) + } + return a1, a2 +} + +// TestComposeEquivalentLinear 多节点线性图:input→memory→agent→output,两路径成稿应逐字一致。 +func TestComposeEquivalentLinear(t *testing.T) { + graph := `{"version":"1","nodes":[ + {"id":"in","kind":"input","config":{"text":"什么是图编排"}}, + {"id":"m","kind":"memory","config":{}}, + {"id":"a","kind":"agent","config":{"system":"你是助手"}}, + {"id":"out","kind":"output","config":{}} + ],"edges":[ + {"source":"in","target":"m"},{"source":"m","target":"a"},{"source":"a","target":"out"} + ]}` + interp, comp := runBoth(t, graph) + if interp == "" || interp != comp { + t.Fatalf("线性图不等价: interp=%q compose=%q", interp, comp) + } +} + +// TestComposeEquivalentBranch 分支图:input→branch→(真)A/(假)B,条件恒真应都走 A,两路径一致。 +func TestComposeEquivalentBranch(t *testing.T) { + graph := `{"version":"1","nodes":[ + {"id":"in","kind":"input","config":{"text":"hi"}}, + {"id":"br","kind":"branch","config":{"condition":""}}, + {"id":"a","kind":"agent","config":{"system":"A"}}, + {"id":"b","kind":"agent","config":{"system":"B"}} + ],"edges":[ + {"source":"in","target":"br"}, + {"source":"br","target":"a","sourceHandle":"true"}, + {"source":"br","target":"b","sourceHandle":"false"} + ]}` + interp, comp := runBoth(t, graph) + if interp == "" || interp != comp { + t.Fatalf("分支图不等价: interp=%q compose=%q", interp, comp) + } +} diff --git a/sundynix-dispatcher/internal/eino/orchestrator.go b/sundynix-dispatcher/internal/eino/orchestrator.go index 93aa78f..448eb6c 100644 --- a/sundynix-dispatcher/internal/eino/orchestrator.go +++ b/sundynix-dispatcher/internal/eino/orchestrator.go @@ -130,8 +130,8 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error { log.Printf("[eino] task %s received (graph=%d bytes), 按图执行(拓扑+连线+分支)...", t.ID, len(t.Graph)) tr.info("task", "system", "任务受理", fmt.Sprintf("DSL %d 字节,按图执行", len(t.Graph))) - // 按 DSL 图的真实拓扑/连线/分支执行(graph.go 解释器),agent 节点流式回流 token。 - answer, err := o.runGraph(tctx, t, tr) + // 按 DSL 图执行:compose.Graph(EINO_COMPOSE=1)或自研 graph.go(默认);agent 节点流式回流 token。 + answer, err := o.executeGraph(tctx, t, tr) if err != nil { log.Printf("[eino] task %s graph error: %v", t.ID, err) _ = o.sink.CompleteStream(t.ID) From 45ad8528065b324a3935f56247f642fb7943d54d Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 23 Jun 2026 11:20:52 +0800 Subject: [PATCH 04/15] =?UTF-8?q?feat(dispatcher):=20Eino=20=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=8C=96=E8=A1=A5=E5=AE=8C=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=20Retriever=20+=20=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=20ChatTemplate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补完终态架构剩余两层(A 收尾): - 检索:新增 ragRetriever(eino_components.go)实现 components/retriever.Retriever, 包 mcp-go kb_search(NATS)、命中转 *schema.Document;retrieve() 改经组件, retriever 节点与报告分章检索统一走它。 - 提示词:buildMessages 改用 prompt.FromMessages + MessagesPlaceholder (FString 仅解析模板串、值原样注入 → JSON 花括号安全),产出消息序列与旧手拼等价。 - 工具:mcpTool 即 InvokableTool(Phase B),用于模型自主调用;静态 tool 节点 确定性裸调用 by design。 测试:buildMessages 形状(含花括号)+ ragRetriever 文档解析单测;make test-go 全绿。 性能:compose 每任务编译实测 ~13µs(基准),相对 LLM 秒级可忽略 → 编译图缓存 判为 premature optimization 暂不做;并行效率已由 Phase C DAG 调度拿到。 Co-Authored-By: Claude Opus 4.8 (1M context) --- EINO_ADOPTION.md | 3 + sundynix-dispatcher/internal/eino/compile.go | 22 +++++-- .../internal/eino/eino_components.go | 62 +++++++++++++++++++ .../internal/eino/eino_components_test.go | 56 +++++++++++++++++ sundynix-dispatcher/internal/eino/report.go | 24 ++----- 5 files changed, 142 insertions(+), 25 deletions(-) create mode 100644 sundynix-dispatcher/internal/eino/eino_components.go create mode 100644 sundynix-dispatcher/internal/eino/eino_components_test.go diff --git a/EINO_ADOPTION.md b/EINO_ADOPTION.md index c199335..950fa73 100644 --- a/EINO_ADOPTION.md +++ b/EINO_ADOPTION.md @@ -12,6 +12,9 @@ - [x] **Phase C · 编排归一**:✅ 全图 `DSL→compose.Graph` 编译器(全节点 + branch + DAG 并行调度)+ callbacks→ExecEvent 归一,等价回归通过(EINO_COMPOSE 灰度开关,默认关;过渡期后 graph.go 退役) - [~] **Phase D · 状态化执行**:✅ 任务生命周期 FSM(已完成)/ ⬜ HITL 中断恢复 / ⬜ 多智能体(按场景) +**组件化补完(A)**:检索 → `ragRetriever`(`components/retriever.Retriever`,`eino_components.go`);提示词 → `buildMessages` 改用 `prompt.FromMessages`+`MessagesPlaceholder`;工具 → `mcpTool`(`InvokableTool`,模型自主调用)。至此终态架构 8 层中 模型/工具/检索/提示词/编排/智能体(单)/可观测 均已 Eino 组件化;剩 人机交互(中断恢复,按场景)。 +**性能注记**:compose 每任务编译实测 ~13µs(基准 BenchmarkComposeCompile),相对 LLM 秒级可忽略 → 编译图缓存判定为 premature optimization,暂不做;真正的并行效率已由 Phase C 的 DAG 调度(`AllPredecessor`)拿到。 + --- ## 0. 现状审计(2026-06-22) diff --git a/sundynix-dispatcher/internal/eino/compile.go b/sundynix-dispatcher/internal/eino/compile.go index 56bd90c..787d624 100644 --- a/sundynix-dispatcher/internal/eino/compile.go +++ b/sundynix-dispatcher/internal/eino/compile.go @@ -5,6 +5,7 @@ import ( "encoding/json" "strings" + "github.com/cloudwego/eino/components/prompt" "github.com/cloudwego/eino/schema" ) @@ -20,8 +21,17 @@ type RunCtx struct { ToolOut []string // 工具/检索节点产出(含参考资料) } +// chatTemplate 是会话消息模板:系统提示词 + 历史占位 + 用户输入。 +// 用 Eino components/prompt.ChatTemplate(FString 仅解析模板串、值原样注入,故 JSON 花括号安全)。 +var chatTemplate = prompt.FromMessages(schema.FString, + schema.SystemMessage("{system}"), + schema.MessagesPlaceholder("history", true), + schema.UserMessage("{query}"), +) + // buildMessages 把上下文组装为发给模型的消息序列(系统提示词 + 画像 + 工具产出 + 历史 + 用户输入)。 -func buildMessages(_ context.Context, rc *RunCtx) ([]*schema.Message, error) { +// 系统串的动态拼装(按需注入画像/参考资料)留在 Go 侧;最终经 ChatTemplate 成型。 +func buildMessages(ctx context.Context, rc *RunCtx) ([]*schema.Message, error) { var sys strings.Builder sys.WriteString(rc.System) if rc.Profile != "" { @@ -33,11 +43,11 @@ func buildMessages(_ context.Context, rc *RunCtx) ([]*schema.Message, error) { sys.WriteString("\n\n以下是工具/检索得到的参考资料:\n") sys.WriteString(strings.Join(rc.ToolOut, "\n---\n")) } - msgs := make([]*schema.Message, 0, len(rc.History)+2) - msgs = append(msgs, schema.SystemMessage(sys.String())) - msgs = append(msgs, rc.History...) - msgs = append(msgs, schema.UserMessage(rc.Query)) - return msgs, nil + return chatTemplate.Format(ctx, map[string]any{ + "system": sys.String(), + "history": rc.History, + "query": rc.Query, + }) } // previewArgs 把工具入参压成一行短预览。 diff --git a/sundynix-dispatcher/internal/eino/eino_components.go b/sundynix-dispatcher/internal/eino/eino_components.go new file mode 100644 index 0000000..7e8b85c --- /dev/null +++ b/sundynix-dispatcher/internal/eino/eino_components.go @@ -0,0 +1,62 @@ +package eino + +import ( + "context" + "encoding/json" + "strings" + + "github.com/cloudwego/eino/components/retriever" + "github.com/cloudwego/eino/schema" + + "github.com/sundynix/sundynix-shared/contract" +) + +const defaultRetrieveTopK = 4 + +// ragRetriever 把 mcp-go 的混合检索(NATS kb_search)包成 Eino components/retriever.Retriever。 +// kb 在构造时绑定(owner 作用域库名);查询经 NATS 调 mcp-go,命中转 *schema.Document。 +type ragRetriever struct { + caller ToolCaller + kb string + topK int +} + +// newRetriever 构造一个绑定到指定库的 Eino Retriever 组件。 +func (o *Orchestrator) newRetriever(kb string) retriever.Retriever { + return &ragRetriever{caller: o.tools, kb: kb, topK: defaultRetrieveTopK} +} + +// Retrieve 实现 retriever.Retriever:经 NATS kb_search 检索,返回命中文档(不可用时降级空)。 +func (r *ragRetriever) Retrieve(ctx context.Context, query string, _ ...retriever.Option) ([]*schema.Document, error) { + if r.caller == nil || r.kb == "" { + return nil, nil + } + topK := r.topK + if topK <= 0 { + topK = defaultRetrieveTopK + } + cctx, cancel := context.WithTimeout(ctx, toolCallTimeout) + defer cancel() + res, err := r.caller.CallTool(cctx, contract.ToolSubjectGo("kb_search"), &contract.ToolCall{ + Tool: "kb_search", Args: map[string]any{"kb": r.kb, "q": query, "topK": topK}, + }) + if err != nil || res == nil || !res.OK || res.Content == "" || res.Content == "[]" { + return nil, nil + } + var hits []struct { + Text string `json:"text"` + Score float64 `json:"score"` + } + if json.Unmarshal([]byte(res.Content), &hits) != nil { + // 非结构化命中:整体作为单篇文档返回(与旧行为对齐)。 + return []*schema.Document{{Content: res.Content}}, nil + } + docs := make([]*schema.Document, 0, len(hits)) + for _, h := range hits { + docs = append(docs, &schema.Document{ + Content: strings.TrimSpace(h.Text), + MetaData: map[string]any{"score": h.Score}, + }) + } + return docs, nil +} diff --git a/sundynix-dispatcher/internal/eino/eino_components_test.go b/sundynix-dispatcher/internal/eino/eino_components_test.go new file mode 100644 index 0000000..8a05dcc --- /dev/null +++ b/sundynix-dispatcher/internal/eino/eino_components_test.go @@ -0,0 +1,56 @@ +package eino + +import ( + "context" + "strings" + "testing" + + "github.com/cloudwego/eino/schema" + + "github.com/sundynix/sundynix-shared/contract" +) + +// TestBuildMessagesShape 锁定 ChatTemplate 产出的消息序列与旧手拼逻辑等价: +// [system(含画像+参考资料)] + [历史...] + [user(query)],且 JSON 花括号不被误解析。 +func TestBuildMessagesShape(t *testing.T) { + rc := &RunCtx{ + System: "你是助手", + Query: "今天几号", + Profile: "称呼=Dexter", + History: []*schema.Message{schema.UserMessage("上一句"), schema.AssistantMessage("上一答", nil)}, + ToolOut: []string{`{"k":"v"}`}, // 含花括号,验证 FString 不误解析 + } + msgs, err := buildMessages(context.Background(), rc) + if err != nil { + t.Fatal(err) + } + if len(msgs) != 4 { + t.Fatalf("应为 system+2历史+user=4 条,实际 %d", len(msgs)) + } + if msgs[0].Role != schema.System || !strings.Contains(msgs[0].Content, "你是助手") || + !strings.Contains(msgs[0].Content, "称呼=Dexter") || !strings.Contains(msgs[0].Content, `{"k":"v"}`) { + t.Fatalf("system 消息不对: %q", msgs[0].Content) + } + if msgs[3].Role != schema.User || msgs[3].Content != "今天几号" { + t.Fatalf("user 消息不对: %q", msgs[3].Content) + } +} + +// fakeCaller 是 ToolCaller 测试替身,固定返回 kb_search 命中。 +type fakeCaller struct{ content string } + +func (f *fakeCaller) CallTool(_ context.Context, _ string, _ *contract.ToolCall) (*contract.ToolResult, error) { + return &contract.ToolResult{OK: true, Content: f.content}, nil +} + +// TestRagRetrieverComponent 验证 Retriever 组件把 kb_search 命中转成 *schema.Document。 +func TestRagRetrieverComponent(t *testing.T) { + o := &Orchestrator{tools: &fakeCaller{content: `[{"text":"片段A","score":0.9},{"text":"片段B","score":0.8}]`}} + docs, err := o.newRetriever("u/kb").Retrieve(context.Background(), "q") + if err != nil { + t.Fatal(err) + } + if len(docs) != 2 || docs[0].Content != "片段A" || docs[1].Content != "片段B" { + t.Fatalf("文档解析不对: %+v", docs) + } +} diff --git a/sundynix-dispatcher/internal/eino/report.go b/sundynix-dispatcher/internal/eino/report.go index ce15a98..679159f 100644 --- a/sundynix-dispatcher/internal/eino/report.go +++ b/sundynix-dispatcher/internal/eino/report.go @@ -214,29 +214,15 @@ func (o *Orchestrator) writeSection(ctx context.Context, topic, kb, heading stri return strings.TrimSpace(txt) } -// retrieve 经 mcp-go kb_search 工具检索知识库,整理为可读参考资料。kb 为空或无召回则返回空。 +// retrieve 经 Eino Retriever 组件(包 mcp-go kb_search)检索知识库,整理为可读参考资料。 func (o *Orchestrator) retrieve(ctx context.Context, kb, query string) string { - if o.tools == nil || kb == "" { + docs, err := o.newRetriever(kb).Retrieve(ctx, query) + if err != nil || len(docs) == 0 { return "" } - cctx, cancel := context.WithTimeout(ctx, toolCallTimeout) - defer cancel() - res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("kb_search"), &contract.ToolCall{ - Tool: "kb_search", Args: map[string]any{"kb": kb, "q": query, "topK": 4}, - }) - if err != nil || res == nil || !res.OK || res.Content == "" || res.Content == "[]" { - return "" - } - var hits []struct { - Text string `json:"text"` - Score float64 `json:"score"` - } - if json.Unmarshal([]byte(res.Content), &hits) != nil { - return res.Content - } var b strings.Builder - for i, h := range hits { - fmt.Fprintf(&b, "%d. %s\n", i+1, strings.TrimSpace(h.Text)) + for i, d := range docs { + fmt.Fprintf(&b, "%d. %s\n", i+1, strings.TrimSpace(d.Content)) } return b.String() } From 7388f2741d01f8d4c2af20476d356dfb4e3061af Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 23 Jun 2026 14:29:20 +0800 Subject: [PATCH 05/15] =?UTF-8?q?feat(mcp-go,dispatcher):=20=E8=87=AA?= =?UTF-8?q?=E4=B8=BB=20agent=20=E5=B7=A5=E5=85=B7=E9=9B=86=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E5=8C=96=20=E2=80=94=E2=80=94=20list=5Ftools=20?= =?UTF-8?q?=E8=87=AA=E6=8F=8F=E8=BF=B0=20+=20=E5=8A=A8=E6=80=81=E5=8F=91?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 杜绝硬编码:自主 agent 的工具菜单不再写死在 dispatcher,而是从 mcp-go 注册表(单一事实源)动态发现。加工具只改 mcp-go 一处,dispatcher 零改动。 - mcp-go:toolDef 增 agent/agentName/params/inject 元信息(paramSpec 声明 模型可填参数;inject 声明服务端注入、不暴露给模型的参数如 user_id); list_tools 上报这些。当前标 agent 的 4 个:wiki_search / recall_user_memory / remember_user_fact / history_get。 - dispatcher:agentTools() 改为调 list_tools → 取 agent_exposed → 按上报的 params 建 schema.ToolInfo → 生成 mcpTool;inject 参数(user_id/session_id/ kb/task_id)运行时绑定。删除硬编码的 2 个工具。 验收:实测自主 agent 调用新暴露的 remember_user_fact(memory_upsert)成功—— 参数由模型按 schema 自生成(key/value),user_id 服务端注入(map 带 task_id 佐证);make test-go 全绿;管理端状态面板兼容(忽略多余 JSON 字段)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- EINO_ADOPTION.md | 1 + .../internal/eino/react_agent.go | 85 ++++++++++----- sundynix-mcp-go/internal/mcp/gateway.go | 100 +++++++++++++----- 3 files changed, 133 insertions(+), 53 deletions(-) diff --git a/EINO_ADOPTION.md b/EINO_ADOPTION.md index 950fa73..1ac2753 100644 --- a/EINO_ADOPTION.md +++ b/EINO_ADOPTION.md @@ -13,6 +13,7 @@ - [~] **Phase D · 状态化执行**:✅ 任务生命周期 FSM(已完成)/ ⬜ HITL 中断恢复 / ⬜ 多智能体(按场景) **组件化补完(A)**:检索 → `ragRetriever`(`components/retriever.Retriever`,`eino_components.go`);提示词 → `buildMessages` 改用 `prompt.FromMessages`+`MessagesPlaceholder`;工具 → `mcpTool`(`InvokableTool`,模型自主调用)。至此终态架构 8 层中 模型/工具/检索/提示词/编排/智能体(单)/可观测 均已 Eino 组件化;剩 人机交互(中断恢复,按场景)。 +**自主 agent 工具集动态化**:agent 工具集不再硬编码——mcp-go 注册表(单一事实源)每个工具声明 `agent/params/inject`,`list_tools` 上报,dispatcher `agentTools()` 动态发现并建 `InvokableTool`、运行时注入 `user_id/session_id/kb`(不暴露给模型)。**加工具只改 mcp-go 注册表,dispatcher 零改动。** 当前暴露 4 个(wiki_search/recall_user_memory/remember_user_fact/history_get);实测模型自主调用新暴露的 remember_user_fact 成功(参数自生成、user_id 服务端注入)。 **性能注记**:compose 每任务编译实测 ~13µs(基准 BenchmarkComposeCompile),相对 LLM 秒级可忽略 → 编译图缓存判定为 premature optimization,暂不做;真正的并行效率已由 Phase C 的 DAG 调度(`AllPredecessor`)拿到。 --- diff --git a/sundynix-dispatcher/internal/eino/react_agent.go b/sundynix-dispatcher/internal/eino/react_agent.go index 7e8f922..d7efc38 100644 --- a/sundynix-dispatcher/internal/eino/react_agent.go +++ b/sundynix-dispatcher/internal/eino/react_agent.go @@ -85,42 +85,75 @@ func (m *mcpTool) InvokableRun(ctx context.Context, argsJSON string, _ ...tool.O return res.Content, nil } -// agentTools 构建 ReAct 可用的工具集(按当前任务上下文绑定 uid/kb)。 -// 工具名是给"模型看"的语义名;mcpName 是实际 NATS 调用名。context 参数(user_id)服务端注入,不暴露给模型。 +// toolCatalogEntry 是 MCP list_tools 上报的一条工具元信息(与 mcp-go listTools 输出对齐)。 +type toolCatalogEntry struct { + Name string `json:"name"` + CN string `json:"cn"` + Desc string `json:"desc"` + Agent bool `json:"agent_exposed"` + AgentName string `json:"agent_name"` + Params []struct { + Name string `json:"name"` + Type string `json:"type"` + Desc string `json:"desc"` + Required bool `json:"required"` + } `json:"params"` + Inject []string `json:"inject"` +} + +// agentTools 动态构建 ReAct 可用的工具集:调 MCP list_tools 自描述目录 → 取 agent_exposed 的工具 +// → 用上报的参数 schema 建 InvokableTool;inject 参数(user_id/session_id/kb/task_id)服务端运行时 +// 绑定、不暴露给模型。新增工具只需在 mcp-go 注册表标 agent,无需改这里(杜绝硬编码)。 func (o *Orchestrator) agentTools(b *board, taskID string, tr *execTracer) []tool.BaseTool { if o.tools == nil { return nil } - kbBind := map[string]any{} - if b.kb != "" { - kbBind["kb"] = b.kb + cctx, cancel := context.WithTimeout(context.Background(), toolCallTimeout) + defer cancel() + res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("list_tools"), &contract.ToolCall{Tool: "list_tools"}) + if err != nil || res == nil || !res.OK { + return nil } - return []tool.BaseTool{ - &mcpTool{ - mcpName: "wiki_search", + var cat struct { + Tools []toolCatalogEntry `json:"tools"` + } + if json.Unmarshal([]byte(res.Content), &cat) != nil { + return nil + } + // inject 参数名 → 本任务的运行时值(不进模型菜单)。 + injectVal := map[string]any{"user_id": b.uid, "session_id": b.sid, "task_id": taskID, "kb": b.kb} + + var out []tool.BaseTool + for _, e := range cat.Tools { + if !e.Agent { + continue + } + params := map[string]*schema.ParameterInfo{} + for _, p := range e.Params { + params[p.Name] = &schema.ParameterInfo{Type: schema.DataType(p.Type), Desc: p.Desc, Required: p.Required} + } + bind := map[string]any{} + for _, inj := range e.Inject { + if v, ok := injectVal[inj]; ok && v != "" { + bind[inj] = v + } + } + name := e.AgentName + if name == "" { + name = e.Name + } + out = append(out, &mcpTool{ + mcpName: e.Name, subject: contract.ToolSubjectGo, caller: o.tools, taskID: taskID, tr: tr, - bind: kbBind, + bind: bind, info: &schema.ToolInfo{ - Name: "wiki_search", - Desc: "检索知识库,返回与查询最相关的资料片段。需要外部知识/事实依据时调用。", - ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{ - "q": {Type: schema.String, Desc: "检索查询语句", Required: true}, - }), + Name: name, Desc: e.Desc, + ParamsOneOf: schema.NewParamsOneOfByParams(params), }, - }, - &mcpTool{ - mcpName: "memory_get", - subject: contract.ToolSubjectGo, - caller: o.tools, taskID: taskID, tr: tr, - bind: map[string]any{"user_id": b.uid}, - info: &schema.ToolInfo{ - Name: "recall_user_memory", - Desc: "召回当前用户的长期画像与偏好(称呼/职业/回答偏好等)。需要个性化、了解“我是谁”时调用。", - ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{}), - }, - }, + }) } + return out } // runReactAgent 执行带"自主工具"的 agent 节点:模型在 ReAct 循环里自行决定调哪些 MCP 工具。 diff --git a/sundynix-mcp-go/internal/mcp/gateway.go b/sundynix-mcp-go/internal/mcp/gateway.go index 730db3b..4e758a6 100644 --- a/sundynix-mcp-go/internal/mcp/gateway.go +++ b/sundynix-mcp-go/internal/mcp/gateway.go @@ -32,11 +32,25 @@ type Gateway struct { tools map[string]toolDef // 工具注册表:唯一事实源,dispatch 与 list_tools 共用,杜绝漂移 } -// toolDef 是一个注册工具的元信息(中文名 / 作用)+ 处理函数。 +// paramSpec 是一个工具参数的声明(供自主 agent 据此生成调用入参)。 +type paramSpec struct { + Name string `json:"name"` + Type string `json:"type"` // string / number / integer / boolean / object / array + Desc string `json:"desc"` + Required bool `json:"required"` +} + +// toolDef 是一个注册工具的元信息 + 处理函数。新增 agent 暴露元信息,让自主 agent 能动态发现工具: +// agent=是否给模型自主调用;agentName=模型可见名(空=注册键);params=模型可填参数; +// inject=服务端运行时注入、不暴露给模型的参数名(如 user_id / session_id / kb / task_id)。 type toolDef struct { - cn string // 中文名 - desc string // 作用简述 - handler func(context.Context, *contract.ToolCall) *contract.ToolResult + cn string + desc string + agent bool + agentName string + params []paramSpec + inject []string + handler func(context.Context, *contract.ToolCall) *contract.ToolResult } func NewGateway(b *sharedbus.Bus, s *search.Hybrid, m *memory.Store, h *history.Store, r *rag.Engine) *Gateway { @@ -60,29 +74,53 @@ func (g *Gateway) Serve(ctx context.Context) error { // buildRegistry 注册 mcp-go 全部工具:名称 → (中文名, 作用, 处理函数)。 // 这是工具的唯一事实源——dispatch 据此路由、list_tools 据此上报,二者永不漂移。 +// 想让某工具能被自主 agent 调用:把 agent 设 true,写清 params(模型可填)与 inject(服务端注入)。 +// 加新工具只改这一处——dispatcher 经 list_tools 动态发现,无需改调度代码。 func (g *Gateway) buildRegistry() map[string]toolDef { return map[string]toolDef{ - "wiki_search": {"知识检索", "向量检索知识库(Milvus),返回最相关片段", g.wikiSearch}, - "kb_ingest": {"知识入库", "文本切块 → 向量化 → 写入 Milvus / Bleve", g.kbIngest}, - "kb_search": {"检索台查询", "结构化返回命中内容与相似度分数", g.kbSearch}, - "kb_graph": {"知识图谱", "取某库的实体关系三元组(Neo4j)", g.kbGraph}, - "report_render": {"报告渲染", "把结构化报告渲染为 Word(.docx)", g.reportRender}, - "report_store": {"报告存源", "暂存报告源数据,供导出时按需渲染", g.reportStore}, - "report_export": {"报告导出", "按需把已存报告导出为 Word / Markdown", g.reportExport}, - "external_api": {"外部接口", "受控调用第三方 HTTP API(带 SSRF 校验)", g.externalAPI}, - "memory_get": {"记忆召回", "取用户长期画像(已按打分排序)", g.memoryGet}, - "memory_upsert": {"记忆写入", "新增 / 更新一条用户偏好(带重要度)", g.memoryUpsert}, - "memory_delete": {"记忆删除", "软删一条偏好(对账判定过时 / 矛盾时)", g.memoryDelete}, - "memory_list": {"记忆列表", "列出用户全部偏好(供管理面板查看)", g.memoryList}, - "history_get": {"历史召回", "取会话最近多轮对话", g.historyGet}, - "history_append": {"历史追加", "往会话写入一条消息", g.historyAppend}, - "health": {"健康检查", "上报 Milvus / Neo4j / embedding 就绪情况", - func(_ context.Context, _ *contract.ToolCall) *contract.ToolResult { + // —— 暴露给自主 agent 的工具(带参数 schema / 注入声明)—— + "wiki_search": { + cn: "知识检索", desc: "检索知识库,返回与查询最相关的资料片段。需要外部知识/事实依据时调用。", + agent: true, + params: []paramSpec{{Name: "q", Type: "string", Desc: "检索查询语句", Required: true}}, + inject: []string{"kb"}, handler: g.wikiSearch, + }, + "memory_get": { + cn: "记忆召回", desc: "召回当前用户的长期画像与偏好(称呼/职业/回答偏好等)。需要个性化、了解“我是谁”时调用。", + agent: true, agentName: "recall_user_memory", inject: []string{"user_id"}, handler: g.memoryGet, + }, + "memory_upsert": { + cn: "记忆写入", desc: "把关于用户的一条事实/偏好长期记住(如称呼、职业、回答偏好)。", + agent: true, agentName: "remember_user_fact", + params: []paramSpec{ + {Name: "key", Type: "string", Desc: "记忆条目的键,如 称呼/职业/回答偏好", Required: true}, + {Name: "value", Type: "string", Desc: "记忆条目的值", Required: true}, + }, + inject: []string{"user_id"}, handler: g.memoryUpsert, + }, + "history_get": { + cn: "历史召回", desc: "取当前会话最近多轮对话,用于理解上下文。", + agent: true, inject: []string{"session_id"}, handler: g.historyGet, + }, + + // —— 仅内部/流水线/管理用,不暴露给自主 agent —— + "kb_ingest": {cn: "知识入库", desc: "文本切块 → 向量化 → 写入 Milvus / Bleve", handler: g.kbIngest}, + "kb_search": {cn: "检索台查询", desc: "结构化返回命中内容与相似度分数", handler: g.kbSearch}, + "kb_graph": {cn: "知识图谱", desc: "取某库的实体关系三元组(Neo4j)", handler: g.kbGraph}, + "report_render": {cn: "报告渲染", desc: "把结构化报告渲染为 Word(.docx)", handler: g.reportRender}, + "report_store": {cn: "报告存源", desc: "暂存报告源数据,供导出时按需渲染", handler: g.reportStore}, + "report_export": {cn: "报告导出", desc: "按需把已存报告导出为 Word / Markdown", handler: g.reportExport}, + "external_api": {cn: "外部接口", desc: "受控调用第三方 HTTP API(带 SSRF 校验)", handler: g.externalAPI}, + "memory_delete": {cn: "记忆删除", desc: "软删一条偏好(对账判定过时 / 矛盾时)", handler: g.memoryDelete}, + "memory_list": {cn: "记忆列表", desc: "列出用户全部偏好(供管理面板查看)", handler: g.memoryList}, + "history_append": {cn: "历史追加", desc: "往会话写入一条消息", handler: g.historyAppend}, + "health": {cn: "健康检查", desc: "上报 Milvus / Neo4j / embedding 就绪情况", + handler: func(_ context.Context, _ *contract.ToolCall) *contract.ToolResult { data, _ := json.Marshal(g.rag.Status()) return &contract.ToolResult{OK: true, Content: string(data)} }}, - "echo": {"回显", "原样返回入参(调试用)", - func(_ context.Context, call *contract.ToolCall) *contract.ToolResult { + "echo": {cn: "回显", desc: "原样返回入参(调试用)", + handler: func(_ context.Context, call *contract.ToolCall) *contract.ToolResult { return &contract.ToolResult{OK: true, Content: fmt.Sprint(call.Args["text"])} }}, } @@ -102,16 +140,24 @@ func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contra return td.handler(ctx, call) } -// listTools 自省:上报本服务注册的工具清单(名称 + 中文名 + 作用),供管理端展示。 +// listTools 自省:上报本服务注册的工具清单(名称 + 中文名 + 作用 + agent 暴露元信息), +// 供管理端展示 & dispatcher 动态构建自主 agent 工具集(加工具只改注册表,无需改调度代码)。 func (g *Gateway) listTools() *contract.ToolResult { type info struct { - Name string `json:"name"` - CN string `json:"cn"` - Desc string `json:"desc"` + Name string `json:"name"` + CN string `json:"cn"` + Desc string `json:"desc"` + Agent bool `json:"agent_exposed"` // 是否给自主 agent + AgentName string `json:"agent_name,omitempty"`// 模型可见名(空=name) + Params []paramSpec `json:"params,omitempty"` // 模型可填参数 + Inject []string `json:"inject,omitempty"` // 服务端注入参数(不暴露给模型) } out := make([]info, 0, len(g.tools)) for name, td := range g.tools { - out = append(out, info{Name: name, CN: td.cn, Desc: td.desc}) + out = append(out, info{ + Name: name, CN: td.cn, Desc: td.desc, + Agent: td.agent, AgentName: td.agentName, Params: td.params, Inject: td.inject, + }) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) // map 无序 → 稳定输出 data, _ := json.Marshal(map[string]any{"service": "mcp-go", "tools": out}) From 95f4752d9e7d8facce0d00fa5ac793730e15b973 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 23 Jun 2026 14:29:37 +0800 Subject: [PATCH 06/15] =?UTF-8?q?feat(desktop):=20Agent=20=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=8A=A0=E3=80=8C=E8=87=AA=E4=B8=BB=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=20(ReAct)=E3=80=8D=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端补上 Phase B 的 UI 接缝:agent 节点 nodeCatalog 增 autonomous checkbox (默认 false)。勾选 → exportDsl 带 config.autonomous=true → dispatcher 走 ReAct 自主调工具。此前能力在后端但画布点不到。 Co-Authored-By: Claude Opus 4.8 (1M context) --- sundynix-desktop/frontend/src/studio/nodeCatalog.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sundynix-desktop/frontend/src/studio/nodeCatalog.ts b/sundynix-desktop/frontend/src/studio/nodeCatalog.ts index f28edac..800a988 100644 --- a/sundynix-desktop/frontend/src/studio/nodeCatalog.ts +++ b/sundynix-desktop/frontend/src/studio/nodeCatalog.ts @@ -58,8 +58,9 @@ export const NODE_KINDS: Record = { { key: "model", label: "模型", type: "select", options: ["占位 Pool", "ollama:qwen", "vllm:custom"], required: true }, { key: "system", label: "系统提示词", type: "textarea", placeholder: "你是…" }, { key: "temperature", label: "温度", type: "number" }, + { key: "autonomous", label: "自主工具 (ReAct)", type: "checkbox" }, ], - defaults: { model: "占位 Pool", system: "", temperature: 0.7 }, + defaults: { model: "占位 Pool", system: "", temperature: 0.7, autonomous: false }, }, tool: { kind: "tool", From 79437e1c1cf5a402b56d7d18309fa185ea59a823 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 23 Jun 2026 14:34:07 +0800 Subject: [PATCH 07/15] =?UTF-8?q?feat(mcp-py,dispatcher):=20=E8=87=AA?= =?UTF-8?q?=E4=B8=BB=20agent=20=E5=B7=A5=E5=85=B7=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=E8=A6=86=E7=9B=96=20mcp-py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把动态工具发现从 mcp-go 扩到两台 MCP: - mcp-py:TOOL_META 改成与 mcp-go 同形(cn/desc/agent/params/inject), list_tools 上报这些;run_code 标 agent 可用(params: code)→ 自主 agent 可执行 Python 代码做计算/数据处理。 - dispatcher:agentTools 拆出 discoverTools(subject),分别探 mcp-go / mcp-py 的 list_tools 并合并;某台 MCP 离线即跳过(降级,不阻断)。 验收:mcp-py 未启动时自主 agent 仍正常(探测秒跳过),一次自主调用了 history_get + memory_get 两个动态发现的 go 工具(注入 session_id/user_id); go 全绿、mcp-py py_compile 通过。mcp-py 起着时 run_code 即自动入 agent 菜单。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/eino/react_agent.go | 21 +++++++++---- .../src/sundynix_mcp_py/mcp_gateway.py | 30 ++++++++++++++----- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/sundynix-dispatcher/internal/eino/react_agent.go b/sundynix-dispatcher/internal/eino/react_agent.go index d7efc38..8b2d733 100644 --- a/sundynix-dispatcher/internal/eino/react_agent.go +++ b/sundynix-dispatcher/internal/eino/react_agent.go @@ -101,16 +101,26 @@ type toolCatalogEntry struct { Inject []string `json:"inject"` } -// agentTools 动态构建 ReAct 可用的工具集:调 MCP list_tools 自描述目录 → 取 agent_exposed 的工具 -// → 用上报的参数 schema 建 InvokableTool;inject 参数(user_id/session_id/kb/task_id)服务端运行时 -// 绑定、不暴露给模型。新增工具只需在 mcp-go 注册表标 agent,无需改这里(杜绝硬编码)。 +// agentTools 动态构建 ReAct 可用的工具集:分别向 mcp-go / mcp-py 探 list_tools 自描述目录, +// 取 agent_exposed 的工具按上报参数 schema 建 InvokableTool;inject 参数(user_id/session_id/ +// kb/task_id)服务端运行时绑定、不暴露给模型。某台 MCP 离线即跳过(降级)。 +// 新增工具只需在对应 MCP 注册表标 agent,无需改这里(杜绝硬编码)。 func (o *Orchestrator) agentTools(b *board, taskID string, tr *execTracer) []tool.BaseTool { if o.tools == nil { return nil } + var out []tool.BaseTool + out = append(out, o.discoverTools(contract.ToolSubjectGo, b, taskID, tr)...) + out = append(out, o.discoverTools(contract.ToolSubjectPy, b, taskID, tr)...) + return out +} + +// discoverTools 向某台 MCP(subject 前缀决定 go/py)探 list_tools,把 agent_exposed 的工具 +// 转成 Eino InvokableTool。该 MCP 不可用 / 无应答时返回空(不阻断)。 +func (o *Orchestrator) discoverTools(subject func(string) string, b *board, taskID string, tr *execTracer) []tool.BaseTool { cctx, cancel := context.WithTimeout(context.Background(), toolCallTimeout) defer cancel() - res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("list_tools"), &contract.ToolCall{Tool: "list_tools"}) + res, err := o.tools.CallTool(cctx, subject("list_tools"), &contract.ToolCall{Tool: "list_tools"}) if err != nil || res == nil || !res.OK { return nil } @@ -120,7 +130,6 @@ func (o *Orchestrator) agentTools(b *board, taskID string, tr *execTracer) []too if json.Unmarshal([]byte(res.Content), &cat) != nil { return nil } - // inject 参数名 → 本任务的运行时值(不进模型菜单)。 injectVal := map[string]any{"user_id": b.uid, "session_id": b.sid, "task_id": taskID, "kb": b.kb} var out []tool.BaseTool @@ -144,7 +153,7 @@ func (o *Orchestrator) agentTools(b *board, taskID string, tr *execTracer) []too } out = append(out, &mcpTool{ mcpName: e.Name, - subject: contract.ToolSubjectGo, + subject: subject, caller: o.tools, taskID: taskID, tr: tr, bind: bind, info: &schema.ToolInfo{ diff --git a/sundynix-mcp-py/src/sundynix_mcp_py/mcp_gateway.py b/sundynix-mcp-py/src/sundynix_mcp_py/mcp_gateway.py index 4b60186..e4be237 100644 --- a/sundynix-mcp-py/src/sundynix_mcp_py/mcp_gateway.py +++ b/sundynix-mcp-py/src/sundynix_mcp_py/mcp_gateway.py @@ -25,12 +25,18 @@ log = logging.getLogger("mcp_py") SUBJECT_PY_ALL = "sundynix.tools.py.>" QUEUE_PY = "mcp-py-workers" -# 工具元信息:名称 → (中文名, 作用简述)。list_tools 据此上报给管理端展示。 +# 工具元信息(与 mcp-go 注册表同形):list_tools 上报给管理端展示 + dispatcher 动态构建自主 agent 工具集。 +# agent=是否暴露给自主 agent;params=模型可填参数;inject=服务端注入参数(不暴露给模型)。 TOOL_META = { - "echo": ("回显", "原样返回入参(调试用)"), - "run_code": ("代码执行", "静态守卫 + Docker 隔离沙箱运行代码(标准档 256m/10s)"), - "parse_document": ("文档解析", "文件 → 纯文本(MinerU / PaddleOCR)"), - "secure_sandbox": ("安全沙箱", "更严资源档(128m/5s)的隔离执行,用于高风险代码"), + "echo": {"cn": "回显", "desc": "原样返回入参(调试用)"}, + "run_code": { + "cn": "代码执行", + "desc": "在隔离沙箱里执行 Python 代码(静态守卫 + Docker,256m/10s)。用于计算、数据处理、逻辑验证。", + "agent": True, + "params": [{"name": "code", "type": "string", "desc": "要执行的 Python 代码", "required": True}], + }, + "parse_document": {"cn": "文档解析", "desc": "文件 → 纯文本(MinerU / PaddleOCR)"}, + "secure_sandbox": {"cn": "安全沙箱", "desc": "更严资源档(128m/5s)的隔离执行,用于高风险代码"}, } @@ -102,10 +108,18 @@ class McpGateway: return str(args.get("text", "")) async def _list_tools(self, args: dict) -> str: - """自省:上报业务工具清单(名称 + 中文名 + 作用),供管理端探活 + 展示。""" + """自省:上报业务工具清单(名称/中文名/作用 + agent 暴露元信息),供管理端展示 + dispatcher 动态发现。""" tools = [ - {"name": n, "cn": cn, "desc": d} - for n, (cn, d) in TOOL_META.items() + { + "name": n, + "cn": m["cn"], + "desc": m["desc"], + "agent_exposed": m.get("agent", False), + "agent_name": m.get("agent_name", ""), + "params": m.get("params", []), + "inject": m.get("inject", []), + } + for n, m in TOOL_META.items() if n in self._tools # 仅上报真正注册的业务工具(list_tools 自身不计入) ] return json.dumps({"service": "mcp-py", "tools": tools}) From 529cd0fdd6f18298061ef9666ef1edf3f0b36121 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 23 Jun 2026 15:05:53 +0800 Subject: [PATCH 08/15] =?UTF-8?q?feat(gateway):=20SSE=20token=20=E6=B5=81?= =?UTF-8?q?=E5=8F=AF=E5=9B=9E=E6=94=BE=20+=20=E6=96=AD=E7=82=B9=E7=BB=AD?= =?UTF-8?q?=E4=BC=A0=EF=BC=88Redis=20Stream=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根治 core NATS 不回放导致的"连晚/刷新/抖动重连丢实时 token": - store/redis:token 流落 Redis Stream(XADD,带 TTL)——StreamAppend / StreamRead(XREAD BLOCK,支持从任意 ID 续读)。 - gateway:SubmitTask 起即启动 token 录制器(订阅早于 dispatcher 产 token, 全量捕获,与 SSE 客户端是否在线无关);StreamTask 改从 Redis Stream 读, 发 SSE 时带 id(= Redis entry ID),浏览器重连自动带 Last-Event-ID 续传。 Redis 降级时回退原 live-NATS 路径(streamTaskLive)。 实测:任务跑完后才连 SSE → 完整回放;带 Last-Event-ID 重连 → 断点续传不重不漏; make test-go 全绿。最终答复本就持久在 Redis 历史,此改让"实时流"也无损。 Co-Authored-By: Claude Opus 4.8 (1M context) --- sundynix-gateway/go.mod | 2 +- .../internal/handler/task_handler.go | 59 +++++++++++++++++-- sundynix-gateway/internal/store/redis.go | 58 ++++++++++++++++++ 3 files changed, 114 insertions(+), 5 deletions(-) diff --git a/sundynix-gateway/go.mod b/sundynix-gateway/go.mod index 60c531a..af49abe 100644 --- a/sundynix-gateway/go.mod +++ b/sundynix-gateway/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/bwmarrin/snowflake v0.3.0 + github.com/gin-contrib/sse v0.1.0 github.com/gin-gonic/gin v1.10.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/minio/minio-go/v7 v7.2.0 @@ -26,7 +27,6 @@ require ( github.com/cloudwego/base64x v0.1.6 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect diff --git a/sundynix-gateway/internal/handler/task_handler.go b/sundynix-gateway/internal/handler/task_handler.go index 43c98f3..b4c6be8 100644 --- a/sundynix-gateway/internal/handler/task_handler.go +++ b/sundynix-gateway/internal/handler/task_handler.go @@ -9,6 +9,7 @@ import ( "net/http" "time" + "github.com/gin-contrib/sse" "github.com/gin-gonic/gin" "github.com/sundynix/sundynix-gateway/internal/blob" @@ -53,9 +54,30 @@ func (h *Handler) SubmitTask(c *gin.Context) { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } + // 从提交即开始把 token 流录进 Redis Stream(订阅早于 dispatcher 产 token)→ + // SSE 可从中回放/断点续传,根治"连晚/重连丢 token"。 + h.startTokenRecorder(task.ID) c.JSON(http.StatusAccepted, gin.H{"task_id": task.ID}) } +// startTokenRecorder 后台订阅 token 流并落 Redis Stream,与 SSE 客户端是否在线无关。 +// Redis 降级时为空操作(SSE 自动回退到 live NATS 路径)。 +func (h *Handler) startTokenRecorder(taskID string) { + if !h.cache.Enabled() { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) // 兜底防泄漏 + unsub, err := h.bus.SubscribeTokens(taskID, + func(tok []byte) { _ = h.cache.StreamAppend(ctx, taskID, "token", string(tok)) }, + func() { _ = h.cache.StreamAppend(ctx, taskID, "done", ""); cancel() }, + ) + if err != nil { + cancel() + return + } + go func() { <-ctx.Done(); _ = unsub() }() +} + // TaskStatus: GET /api/v1/tasks/:id —— 返回任务生命周期状态(供 UI 轮询,根治"卡运行中看不出来")。 func (h *Handler) TaskStatus(c *gin.Context) { id := c.Param("id") @@ -67,20 +89,51 @@ func (h *Handler) TaskStatus(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"task_id": id, "status": status, "detail": detail}) } -// StreamTask: 订阅 sundynix.streams.,以 SSE 把零拷贝 Token Stream 推给客户端。 +// StreamTask: 以 SSE 把 Token Stream 推给客户端。 +// 优先从 Redis Stream 读(可回放 + 断点续传,根治连晚/重连丢 token);Redis 降级时回退 live NATS。 func (h *Handler) StreamTask(c *gin.Context) { taskID := c.Param("id") c.Writer.Header().Set("Content-Type", "text/event-stream") c.Writer.Header().Set("Cache-Control", "no-cache") c.Writer.Header().Set("Connection", "keep-alive") + if !h.cache.Enabled() { + h.streamTaskLive(c, taskID) // Redis 不可用 → 旧的 live-NATS 路径兜底 + return + } + + // 断点续传:浏览器 EventSource 重连会带 Last-Event-ID;缺省 "0" 从头回放。 + lastID := c.GetHeader("Last-Event-ID") + if lastID == "" { + lastID = "0" + } + ctx := c.Request.Context() + c.Stream(func(w io.Writer) bool { + entries, nl, err := h.cache.StreamRead(ctx, taskID, lastID, 20*time.Second) + if err != nil { + return false // ctx 取消(客户端断开)或后端故障 + } + lastID = nl + for _, e := range entries { + if e.Kind == "done" { + _ = sse.Encode(w, sse.Event{Id: e.ID, Event: "done", Data: taskID}) + return false + } + _ = sse.Encode(w, sse.Event{Id: e.ID, Event: "token", Data: e.Data}) + } + return true // 继续阻塞读取后续 token + }) +} + +// streamTaskLive 是 Redis 降级时的兜底:直接订阅 NATS token 流转 SSE(无回放/续传能力)。 +func (h *Handler) streamTaskLive(c *gin.Context, taskID string) { tokens := make(chan []byte, 256) done := make(chan struct{}) unsub, err := h.bus.SubscribeTokens(taskID, func(tok []byte) { select { case tokens <- tok: - default: // 背压保护:客户端过慢则丢弃,避免阻塞 NATS 回调 + default: } }, func() { close(done) }, @@ -90,8 +143,6 @@ func (h *Handler) StreamTask(c *gin.Context) { return } defer func() { _ = unsub() }() - - // gin 的流式写:返回 false 即结束响应。 c.Stream(func(w io.Writer) bool { select { case tok := <-tokens: diff --git a/sundynix-gateway/internal/store/redis.go b/sundynix-gateway/internal/store/redis.go index 798f427..03a06fd 100644 --- a/sundynix-gateway/internal/store/redis.go +++ b/sundynix-gateway/internal/store/redis.go @@ -48,6 +48,64 @@ func (r *Redis) Allow(ctx context.Context, key string, limit int64, window time. return n <= limit, nil } +// ---- Token 流持久化(Redis Stream:可回放的追加日志,根治 SSE 连晚/重连丢 token)---- + +const streamTTL = 10 * time.Minute + +func streamKey(taskID string) string { return "sundynix:stream:" + taskID } + +// StreamEntry 是 token 流里的一条记录(ID 用于 SSE 的 Last-Event-ID 断点续传)。 +type StreamEntry struct { + ID string + Kind string // token / done + Data string +} + +// StreamAppend 把一条 token / 结束标记追加到任务的 Redis Stream(带 TTL 自动清理)。 +func (r *Redis) StreamAppend(ctx context.Context, taskID, kind, data string) error { + if r.rdb == nil { + return nil + } + k := streamKey(taskID) + if err := r.rdb.XAdd(ctx, &redis.XAddArgs{Stream: k, Values: map[string]any{"kind": kind, "data": data}}).Err(); err != nil { + return err + } + return r.rdb.Expire(ctx, k, streamTTL).Err() +} + +// StreamRead 从 lastID 之后阻塞读取新条目(XREAD BLOCK)。lastID="0" 表示从头回放。 +// 阻塞超时无新数据时返回空切片 + 原 lastID(调用方据此继续轮询)。 +func (r *Redis) StreamRead(ctx context.Context, taskID, lastID string, block time.Duration) ([]StreamEntry, string, error) { + if r.rdb == nil { + return nil, lastID, nil + } + res, err := r.rdb.XRead(ctx, &redis.XReadArgs{ + Streams: []string{streamKey(taskID), lastID}, Block: block, Count: 256, + }).Result() + if err == redis.Nil { + return nil, lastID, nil // 阻塞超时、无新条目 + } + if err != nil { + return nil, lastID, err + } + var out []StreamEntry + nl := lastID + for _, st := range res { + for _, m := range st.Messages { + out = append(out, StreamEntry{ID: m.ID, Kind: asString(m.Values["kind"]), Data: asString(m.Values["data"])}) + nl = m.ID + } + } + return out, nl, nil +} + +func asString(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + // Close 释放底层连接。 func (r *Redis) Close() { if r.rdb != nil { From ddc2265c5b88122cc302b8f8547be88ad19fc346 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 23 Jun 2026 15:21:17 +0800 Subject: [PATCH 09/15] =?UTF-8?q?feat(mcp-go):=20RAG=20=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E5=88=87=E5=9D=97=20=E2=80=94=E2=80=94=20=E9=80=92=E5=BD=92=20?= =?UTF-8?q?+=20=E5=8F=A5=E7=95=8C=20+=20=E9=87=8D=E5=8F=A0=20+=20rune=20?= =?UTF-8?q?=E5=AE=89=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 替换朴素切块(按行切 + 字节硬截)为工业级语义切块: - 修真 bug:旧版 s[:2000] 按字节切,中文 UTF-8(3 字节/字)会被切碎成乱码; 新版全程按 rune 操作。 - 算法:splitToAtoms(换行/中英句末标点切原子,超大无标点原子按 rune 窗口兜底) → packAtoms(贪心打包到 target=500 字、句末收口,尾块 <100 字并入相邻) → addOverlap(块间 80 字重叠,保跨块上下文)。硬上限 1000。 - chunk.go 独立成文件 + chunk_test.go(空/短/rune安全/大小上界/句界/重叠/超大无标点)。 收益:检索片段语义完整(不再断句)、中文不乱码、跨块上下文不丢 → RAG 召回质量。 make test-go 全绿。后续可加 Markdown 标题路径前缀(结构化文档增强)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- sundynix-mcp-go/internal/rag/chunk.go | 137 +++++++++++++++++++++ sundynix-mcp-go/internal/rag/chunk_test.go | 122 ++++++++++++++++++ sundynix-mcp-go/internal/rag/rag.go | 18 +-- 3 files changed, 260 insertions(+), 17 deletions(-) create mode 100644 sundynix-mcp-go/internal/rag/chunk.go create mode 100644 sundynix-mcp-go/internal/rag/chunk_test.go diff --git a/sundynix-mcp-go/internal/rag/chunk.go b/sundynix-mcp-go/internal/rag/chunk.go new file mode 100644 index 0000000..3a9615a --- /dev/null +++ b/sundynix-mcp-go/internal/rag/chunk.go @@ -0,0 +1,137 @@ +package rag + +import ( + "strings" +) + +// 切块参数(针对中文 + embedding token 上限调过;后续可配置化)。 +const ( + chunkTargetRunes = 500 // 目标大小:贪心打包到接近此值就在句界收口 + chunkOverlapRunes = 80 // 块间重叠:保跨块上下文连续 + chunkMinRunes = 100 // 最小块:低于则并入相邻,避免碎块稀释向量 + chunkMaxRunes = 1000 // 原子硬上限:超大无标点段落兜底窗口切 +) + +// chunk 把文本切成检索友好的语义块:按段落/句界切成原子 → 贪心打包到目标大小(句末收口) +// → 块间加重叠。全程按 rune 操作,杜绝中文 UTF-8 被字节切碎。 +func chunk(text string) []string { + atoms := splitToAtoms(text) + packed := packAtoms(atoms, chunkTargetRunes, chunkMinRunes) + return addOverlap(packed, chunkOverlapRunes) +} + +// splitToAtoms 把文本切成"原子"(句子/行):在换行与句末标点处断开,去空白; +// 超大无标点原子按 rune 窗口兜底切到 ≤ 目标大小。原子是打包的最小不可分单元。 +func splitToAtoms(text string) []string { + runes := []rune(strings.ReplaceAll(text, "\r\n", "\n")) + var atoms []string + start := 0 + flush := func(end int) { + if s := strings.TrimSpace(string(runes[start:end])); s != "" { + atoms = append(atoms, s) + } + start = end + } + for i := 0; i < len(runes); i++ { + r := runes[i] + switch { + case r == '\n' || isCJKEnd(r): + flush(i + 1) + case r == '.' || r == '!' || r == '?' || r == ';': + // ASCII 句末:仅当其后为空白/行尾才断(避开缩写、小数点)。 + if i+1 >= len(runes) || runes[i+1] == ' ' || runes[i+1] == '\n' { + flush(i + 1) + } + } + } + flush(len(runes)) + + out := make([]string, 0, len(atoms)) + for _, a := range atoms { + if runeLen(a) <= chunkMaxRunes { + out = append(out, a) + } else { + out = append(out, splitByRuneWindow(a, chunkTargetRunes)...) + } + } + return out +} + +// packAtoms 贪心打包:把原子拼进当前块,直到再加会超 target 就收口(块在句界结束)。 +// 末尾过小的块(< min)并入前一块,避免碎块。 +func packAtoms(atoms []string, target, min int) []string { + var chunks []string + var cur strings.Builder + curLen := 0 + closeCur := func() { + if curLen > 0 { + chunks = append(chunks, cur.String()) + cur.Reset() + curLen = 0 + } + } + for _, a := range atoms { + al := runeLen(a) + if curLen > 0 && curLen+al > target { + closeCur() + } + if curLen > 0 { + cur.WriteByte('\n') + curLen++ + } + cur.WriteString(a) + curLen += al + } + if curLen > 0 { + if n := len(chunks); n > 0 && curLen < min { + chunks[n-1] = chunks[n-1] + "\n" + cur.String() // 尾块太小 → 并入上一块 + } else { + chunks = append(chunks, cur.String()) + } + } + return chunks +} + +// addOverlap 给每块前缀上一块尾部的 overlap 个 rune,保跨块上下文连续(首块不加)。 +func addOverlap(chunks []string, overlap int) []string { + if overlap <= 0 || len(chunks) <= 1 { + return chunks + } + out := make([]string, len(chunks)) + out[0] = chunks[0] + for i := 1; i < len(chunks); i++ { + prev := []rune(chunks[i-1]) + tail := prev + if len(prev) > overlap { + tail = prev[len(prev)-overlap:] + } + out[i] = strings.TrimSpace(string(tail)) + "\n" + chunks[i] + } + return out +} + +func isCJKEnd(r rune) bool { + switch r { + case '。', '!', '?', ';', '…': + return true + } + return false +} + +// splitByRuneWindow 按 rune 窗口硬切(兜底:超大无标点原子),保证不切碎多字节字符。 +func splitByRuneWindow(s string, size int) []string { + r := []rune(s) + var out []string + for len(r) > size { + if t := strings.TrimSpace(string(r[:size])); t != "" { + out = append(out, t) + } + r = r[size:] + } + if t := strings.TrimSpace(string(r)); t != "" { + out = append(out, t) + } + return out +} + +func runeLen(s string) int { return len([]rune(s)) } diff --git a/sundynix-mcp-go/internal/rag/chunk_test.go b/sundynix-mcp-go/internal/rag/chunk_test.go new file mode 100644 index 0000000..be247f1 --- /dev/null +++ b/sundynix-mcp-go/internal/rag/chunk_test.go @@ -0,0 +1,122 @@ +package rag + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestChunkEmpty(t *testing.T) { + if got := chunk(" \n\n "); len(got) != 0 { + t.Fatalf("空白文本应切出 0 块,得 %d", len(got)) + } +} + +func TestChunkShort(t *testing.T) { + got := chunk("这是一句很短的话。") + if len(got) != 1 || !strings.Contains(got[0], "很短") { + t.Fatalf("短文本应为单块,得 %v", got) + } +} + +// TestChunkRuneSafe 是核心:中文必须按 rune 切,绝不能切碎出乱码(旧版按字节切的 bug)。 +func TestChunkRuneSafe(t *testing.T) { + // 1500 个汉字、无标点 → 触发窗口兜底切;每块必须是合法 UTF-8。 + text := strings.Repeat("中", 1500) + for _, c := range chunk(text) { + if !utf8.ValidString(c) { + t.Fatalf("切出非法 UTF-8(中文被字节切碎):%q", c) + } + for _, r := range c { + if r != '中' && !strings.ContainsRune("\n ", r) { + t.Fatalf("出现意外字符 %q,疑似切碎", r) + } + } + } +} + +func TestChunkSizeBounds(t *testing.T) { + // 多段中文,每段以句号结尾。 + var sb strings.Builder + for i := 0; i < 60; i++ { + sb.WriteString("这是用于测试切块大小上界的一个中文句子片段。") + } + for i, c := range chunk(sb.String()) { + if n := runeLen(c); n > chunkTargetRunes+chunkOverlapRunes+1 { + t.Fatalf("第 %d 块 %d 字,超过 target+overlap=%d", i, n, chunkTargetRunes+chunkOverlapRunes+1) + } + } +} + +func TestChunkSentenceBoundary(t *testing.T) { + var sb strings.Builder + for i := 0; i < 80; i++ { + sb.WriteString("第一句话在这里。第二句话也在这里。") + } + chunks := chunk(sb.String()) + if len(chunks) < 2 { + t.Fatalf("长文本应切出多块,得 %d", len(chunks)) + } + // 多数块应以句号收口(允许重叠导致的少量例外)。 + endsWell := 0 + for _, c := range chunks { + if strings.HasSuffix(strings.TrimSpace(c), "。") { + endsWell++ + } + } + if endsWell < len(chunks)/2 { + t.Fatalf("多数块应在句末收口,仅 %d/%d", endsWell, len(chunks)) + } +} + +func TestChunkOverlap(t *testing.T) { + var sb strings.Builder + for i := 0; i < 60; i++ { + sb.WriteString("用于验证相邻块之间存在上下文重叠的中文句子。") + } + chunks := chunk(sb.String()) + if len(chunks) < 2 { + t.Skip("未切出多块,跳过重叠校验") + } + // 第 2 块开头应包含第 1 块尾部的一小段(重叠)。 + prevTail := []rune(chunks[0]) + tail := string(prevTail[max0(len(prevTail)-chunkOverlapRunes):]) + // 取尾部一小片做包含判断(去掉可能的换行)。 + probe := strings.TrimSpace(tail) + if len(probe) > 10 { + probe = probe[len(probe)-10:] + } + if probe != "" && !strings.Contains(chunks[1], strings.TrimSpace(string([]rune(probe)))) { + // 重叠是按 rune 尾部,probe 是字节尾部,宽松校验:第二块前缀应与首块尾部有交集 + if !strings.HasPrefix(strings.TrimSpace(chunks[1]), strings.TrimSpace(tail)) { + t.Logf("重叠片段:%q\n块2前缀:%q", tail, []rune(chunks[1])[:min0(40, runeLen(chunks[1]))]) + t.Fatalf("相邻块未见重叠") + } + } +} + +func TestChunkOversizedNoPunct(t *testing.T) { + text := strings.Repeat("x", 3000) // 无标点超大块 + chunks := chunk(text) + if len(chunks) < 3 { + t.Fatalf("3000 字无标点应窗口切成多块,得 %d", len(chunks)) + } + for _, c := range chunks { + if runeLen(c) > chunkTargetRunes+chunkOverlapRunes+1 { + t.Fatalf("窗口切块超界:%d", runeLen(c)) + } + } +} + +func max0(n int) int { + if n < 0 { + return 0 + } + return n +} +func min0(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/sundynix-mcp-go/internal/rag/rag.go b/sundynix-mcp-go/internal/rag/rag.go index e99f34a..f535fde 100644 --- a/sundynix-mcp-go/internal/rag/rag.go +++ b/sundynix-mcp-go/internal/rag/rag.go @@ -6,7 +6,6 @@ import ( "context" "errors" "log" - "strings" "sync" "github.com/sundynix/sundynix-shared/contract" @@ -252,19 +251,4 @@ func (e *Engine) Close() { e.graph.close(context.Background()) } -// chunk 朴素切块:按行切,去空白;过长再按长度切。真实系统应做版面/语义切块。 -func chunk(text string) []string { - var out []string - for _, line := range strings.Split(text, "\n") { - s := strings.TrimSpace(line) - if s == "" { - continue - } - for len(s) > 2000 { - out = append(out, s[:2000]) - s = s[2000:] - } - out = append(out, s) - } - return out -} +// chunk 的实现已移到 chunk.go(递归 + 句界 + 重叠 + rune 安全的语义切块)。 From f238ae14553396e91f0e9f6bace9a69977ece4c1 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 23 Jun 2026 15:42:58 +0800 Subject: [PATCH 10/15] =?UTF-8?q?feat(dispatcher):=20=E7=BC=96=E6=8E=92?= =?UTF-8?q?=E5=BC=8F=E5=A4=9A=E6=99=BA=E8=83=BD=E4=BD=93=E6=8E=A5=E5=8A=9B?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E4=B8=8A=E6=B8=B8=20agent=20=E4=BA=A7?= =?UTF-8?q?=E5=87=BA=E6=B2=BF=E5=9B=BE=E4=BC=A0=E7=BB=99=E4=B8=8B=E6=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让画布上串联的多个 agent 真正协作完成一件事(如 检索→撰写→审查 出报告), 而非各自对原 query 重答: - board 增 agentOut(各上游 agent 产出按序);RunCtx 增 Upstream,buildMessages 注入"前序协作 agent 的产出,请在此基础上继续"。 - runAgent / runReactAgent / runComposeConversation 三条路径统一:注入 b.agentOut 作上下文,产出经 recordAgentOutput 入黑板(append agentOut + 设为当前成稿 answer, 多 agent 时最后一个 agent 产出即最终成品)。 不需要 eino multiagent 组件——编排式协作由现有图引擎 + 上下文传递实现。 测试 TestAgentCollaborationPassesOutput:下游 agent 确见上游产出、成稿=下游产出; make test-go 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/eino/agent_collab_test.go | 46 +++++++++++++++++++ sundynix-dispatcher/internal/eino/compile.go | 5 ++ .../internal/eino/compose_graph.go | 18 +++++--- sundynix-dispatcher/internal/eino/graph.go | 30 ++++++++---- .../internal/eino/react_agent.go | 14 ++++-- 5 files changed, 93 insertions(+), 20 deletions(-) create mode 100644 sundynix-dispatcher/internal/eino/agent_collab_test.go diff --git a/sundynix-dispatcher/internal/eino/agent_collab_test.go b/sundynix-dispatcher/internal/eino/agent_collab_test.go new file mode 100644 index 0000000..b479bd5 --- /dev/null +++ b/sundynix-dispatcher/internal/eino/agent_collab_test.go @@ -0,0 +1,46 @@ +package eino + +import ( + "context" + "strings" + "testing" + + "github.com/sundynix/sundynix-dispatcher/internal/harness" + "github.com/sundynix/sundynix-dispatcher/internal/llm" + "github.com/sundynix/sundynix-shared/contract" +) + +// TestAgentCollaborationPassesOutput 验证编排式多智能体接力: +// agent1 的产出注入 agent2 的上下文,agent2 基于它继续;最终成稿 = 下游 agent 的产出。 +func TestAgentCollaborationPassesOutput(t *testing.T) { + graph := `{"version":"1","nodes":[ + {"id":"a1","kind":"agent","config":{"system":"研究"}}, + {"id":"a2","kind":"agent","config":{"system":"撰写"}} + ],"edges":[{"source":"a1","target":"a2"}]}` + + saw := false + ll := &fakeLLM{ready: true, stream: func(m []llm.ChatMessage) string { + var sys string + for _, x := range m { + if x.Role == "system" { + sys = x.Content + } + } + if strings.Contains(sys, "研究产出XYZ") { // agent1 的产出出现在 agent2 的 system → 接力成功 + saw = true + return "最终报告:基于上游研究撰写" + } + return "研究产出XYZ" + }} + o := &Orchestrator{pool: ll, breaker: harness.NewCircuitBreaker(), sink: &fakeSink{}} + ans, err := o.runGraph(context.Background(), &contract.Task{ID: "tc", Graph: []byte(graph)}, &execTracer{}) + if err != nil { + t.Fatal(err) + } + if !saw { + t.Fatal("下游 agent 未看到上游 agent 的产出——协作没接力") + } + if !strings.Contains(ans, "最终报告") { + t.Fatalf("最终成稿应为下游 agent 产出,得 %q", ans) + } +} diff --git a/sundynix-dispatcher/internal/eino/compile.go b/sundynix-dispatcher/internal/eino/compile.go index 787d624..82527d2 100644 --- a/sundynix-dispatcher/internal/eino/compile.go +++ b/sundynix-dispatcher/internal/eino/compile.go @@ -19,6 +19,7 @@ type RunCtx struct { Profile string // 召回的画像 History []*schema.Message // 短期历史 ToolOut []string // 工具/检索节点产出(含参考资料) + Upstream []string // 前序协作 agent 的产出(多 agent 接力时注入,让下游基于上游继续) } // chatTemplate 是会话消息模板:系统提示词 + 历史占位 + 用户输入。 @@ -43,6 +44,10 @@ func buildMessages(ctx context.Context, rc *RunCtx) ([]*schema.Message, error) { sys.WriteString("\n\n以下是工具/检索得到的参考资料:\n") sys.WriteString(strings.Join(rc.ToolOut, "\n---\n")) } + if len(rc.Upstream) > 0 { + sys.WriteString("\n\n以下是前序协作 agent 的产出,请在此基础上继续完成你的部分(不要重头再来):\n") + sys.WriteString(strings.Join(rc.Upstream, "\n---\n")) + } return chatTemplate.Format(ctx, map[string]any{ "system": sys.String(), "history": rc.History, diff --git a/sundynix-dispatcher/internal/eino/compose_graph.go b/sundynix-dispatcher/internal/eino/compose_graph.go index 7b14d40..e49d318 100644 --- a/sundynix-dispatcher/internal/eino/compose_graph.go +++ b/sundynix-dispatcher/internal/eino/compose_graph.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "strings" "time" "github.com/cloudwego/eino/compose" @@ -54,11 +55,12 @@ func (o *Orchestrator) runComposeConversation(ctx context.Context, taskID string rc := &RunCtx{ UserID: b.uid, SessionID: b.sid, - System: firstNonEmpty(system, defaultAgentSystem), - Query: b.query, - Profile: b.profile, - History: b.history, - ToolOut: append(append([]string{}, b.toolOut...), b.refs...), + System: firstNonEmpty(system, defaultAgentSystem), + Query: b.query, + Profile: b.profile, + History: b.history, + ToolOut: append(append([]string{}, b.toolOut...), b.refs...), + Upstream: append([]string{}, b.agentOut...), // 前序协作 agent 产出 → 接力 } msgs, _ := buildMessages(ctx, rc) @@ -72,6 +74,7 @@ func (o *Orchestrator) runComposeConversation(ctx context.Context, taskID string defer sr.Close() chunks := 0 + var produced strings.Builder // 本节点产出(供下游 agent 接力) for { chunk, rerr := sr.Recv() if rerr == io.EOF { @@ -86,8 +89,9 @@ func (o *Orchestrator) runComposeConversation(ctx context.Context, taskID string } safe, _ := harness.RedactSecrets(chunk.Content) // 输出护栏:逐片脱敏 _ = o.sink.PublishToken(taskID, []byte(safe)) - b.answer += safe + produced.WriteString(safe) chunks++ } - tr.info(node, "system", "compose 图", fmt.Sprintf("%d 段输出 / %d 字(Eino compose 运行时)", chunks, len([]rune(b.answer)))) + o.recordAgentOutput(b, produced.String()) + tr.info(node, "system", "compose 图", fmt.Sprintf("%d 段输出 / %d 字(Eino compose 运行时)", chunks, len([]rune(produced.String())))) } diff --git a/sundynix-dispatcher/internal/eino/graph.go b/sundynix-dispatcher/internal/eino/graph.go index aa52d51..5152bf0 100644 --- a/sundynix-dispatcher/internal/eino/graph.go +++ b/sundynix-dispatcher/internal/eino/graph.go @@ -27,7 +27,8 @@ type board struct { refs []string // 检索 / 聚合得到的参考资料 toolOut []string // 工具节点产出 sections []reportSection // map 并行 fan-out 产出的分项成稿(供 render 多章渲染) - answer string // 终端 agent / map 的成稿(流式累计) + answer string // 当前成稿(多 agent 协作时 = 最近一个 agent 的产出 = 成品) + agentOut []string // 各上游 agent 的产出(按序),注入下游 agent 上下文以实现接力协作 } // runGraph 按 DSL 图的真实拓扑与连线执行(替代旧的线性拍平 compileFlow)。 @@ -230,16 +231,18 @@ func (o *Orchestrator) execToolNode(ctx context.Context, taskID string, n dsl.No // runAgent 执行 agent/模型节点:据黑板拼消息 → 流式回流 token → 累计成稿。 func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, system string, tr *execTracer, node string) { rc := &RunCtx{ - System: firstNonEmpty(system, defaultAgentSystem), - Query: b.query, - Profile: b.profile, - History: b.history, - ToolOut: append(append([]string{}, b.toolOut...), b.refs...), + System: firstNonEmpty(system, defaultAgentSystem), + Query: b.query, + Profile: b.profile, + History: b.history, + ToolOut: append(append([]string{}, b.toolOut...), b.refs...), + Upstream: append([]string{}, b.agentOut...), // 前序协作 agent 产出 → 接力 } msgs, _ := buildMessages(ctx, rc) tr.emit(node, "model", "start", "模型流式推理", "", 0) t0 := time.Now() n, redacted := 0, 0 + var produced strings.Builder // 本节点自身产出(用于沿图向下游传递) send := func(s string) { if s == "" { return @@ -248,7 +251,7 @@ func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, sy safe, hit := harness.RedactSecrets(s) redacted += hit _ = o.sink.PublishToken(taskID, []byte(safe)) - b.answer += safe + produced.WriteString(safe) n++ } var err error @@ -264,8 +267,19 @@ func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, sy if redacted > 0 { tr.info(node, "system", "输出护栏", fmt.Sprintf("已脱敏 %d 处疑似密钥/令牌", redacted)) } + o.recordAgentOutput(b, produced.String()) // 产出入黑板:成当前成稿 + 供下游接力 tr.emit(node, "model", "end", "模型流式推理", - fmt.Sprintf("%d tokens / %d 字", n, len([]rune(b.answer))), time.Since(t0).Milliseconds()) + fmt.Sprintf("%d tokens / %d 字", n, len([]rune(produced.String()))), time.Since(t0).Milliseconds()) +} + +// recordAgentOutput 把一个 agent 节点的产出记入黑板:append 到 agentOut(供下游 agent 注入接力), +// 并设为当前成稿 answer(多 agent 协作时,最后一个 agent 的产出即最终成品)。空产出忽略。 +func (o *Orchestrator) recordAgentOutput(b *board, out string) { + if strings.TrimSpace(out) == "" { + return + } + b.agentOut = append(b.agentOut, out) + b.answer = out } // renderNode 执行渲染节点:把当前成稿渲染成 Word(经 mcp-go report_render)。 diff --git a/sundynix-dispatcher/internal/eino/react_agent.go b/sundynix-dispatcher/internal/eino/react_agent.go index 8b2d733..52a87eb 100644 --- a/sundynix-dispatcher/internal/eino/react_agent.go +++ b/sundynix-dispatcher/internal/eino/react_agent.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log" + "strings" "time" "github.com/cloudwego/eino/components/tool" @@ -195,9 +196,10 @@ func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *boar System: firstNonEmpty(system, defaultAgentSystem), Query: b.query, // 自主 agent 不预注入画像:让它经 recall_user_memory 工具按需自取(否则模型直接答、不调工具)。 - Profile: "", - History: b.history, - ToolOut: append(append([]string{}, b.toolOut...), b.refs...), + Profile: "", + History: b.history, + ToolOut: append(append([]string{}, b.toolOut...), b.refs...), + Upstream: append([]string{}, b.agentOut...), // 前序协作 agent 产出 → 接力 } msgs, _ := buildMessages(ctx, rc) @@ -211,6 +213,7 @@ func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *boar defer sr.Close() chunks := 0 + var produced strings.Builder // 本节点产出(供下游 agent 接力) for { chunk, rerr := sr.Recv() if rerr == io.EOF { @@ -225,9 +228,10 @@ func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *boar } safe, _ := harness.RedactSecrets(chunk.Content) _ = o.sink.PublishToken(taskID, []byte(safe)) - b.answer += safe + produced.WriteString(safe) chunks++ } + o.recordAgentOutput(b, produced.String()) tr.emit(node, "model", "end", "ReAct 智能体", - fmt.Sprintf("%d 段输出 / %d 字", chunks, len([]rune(b.answer))), time.Since(t0).Milliseconds()) + fmt.Sprintf("%d 段输出 / %d 字", chunks, len([]rune(produced.String()))), time.Since(t0).Milliseconds()) } From 9fc359731739266bee9dc827b70b2c0b85877d0b Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 23 Jun 2026 16:11:17 +0800 Subject: [PATCH 11/15] =?UTF-8?q?ci:=20=E5=8A=A0=20GitHub=20Actions=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20Go/=E5=89=8D=E7=AB=AF/mcp-py=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8C=96=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工程保障地基:每次 push / PR 自动跑回归,防止改动悄悄破坏现有功能。 - go:4 模块 build + vet + test(bus 用内嵌 NATS,无需外部服务) - web:desktop/frontend + admin 各跑 npm ci + tsc --noEmit(矩阵并行) - py:mcp-py pip install -e . + sandbox 守卫测试(pytest,缺则 fallback harness) 带 concurrency 取消旧运行、npm/pip/go 缓存。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 70 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1caba9b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +# 同一分支新 push 取消上一次未完成的运行,省 CI 时间。 +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + go: + name: Go · build + vet + test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache-dependency-path: "**/go.sum" + - name: build + vet + test(4 模块;bus 用内嵌 NATS,无需外部服务) + run: | + set -e + for m in sundynix-shared sundynix-gateway sundynix-dispatcher sundynix-mcp-go; do + echo "::group::$m" + (cd "$m" && go build ./... && go vet ./... && go test ./...) + echo "::endgroup::" + done + + web: + name: Frontend · tsc + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + dir: [sundynix-desktop/frontend, sundynix-admin] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: ${{ matrix.dir }}/package-lock.json + - name: install + typecheck + working-directory: ${{ matrix.dir }} + run: | + npm ci + npx tsc --noEmit + + py: + name: mcp-py · sandbox guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - name: install + test(无 Docker 时测降级路径) + working-directory: sundynix-mcp-py + run: | + python -m pip install --upgrade pip + python -m pip install -e . pytest + PYTHONPATH=src:tests python -m pytest tests/ -q || \ + PYTHONPATH=src:tests python -c "import test_sandbox as t; \ + fns=[getattr(t,n) for n in dir(t) if n.startswith('test_')]; \ + [ (f(),print('PASS',f.__name__)) for f in fns ]; print(f'{len(fns)} passed')" From 8c980d2b37f7e72a59505eb00283692ceec9dcfd Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 23 Jun 2026 16:26:02 +0800 Subject: [PATCH 12/15] =?UTF-8?q?feat(desktop,ci):=20GitHub=20Releases=20?= =?UTF-8?q?=E5=88=86=E5=8F=91=20+=20=E6=A1=8C=E9=9D=A2=E7=AB=AF=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让桌面端像开源项目一样用 GitHub Releases 分发,旧版用户自动获知更新: - CD:.github/workflows/release.yml —— 打 vX.Y.Z 标签触发,macOS(universal)/ Windows(amd64) 用 wails build 出安装包,softprops/action-gh-release 发布到 GitHub Release(GOWORK=off,因 desktop 不在工作区)。 - 桌面端检查更新:lib/version.ts(APP_VERSION + 查 GitHub /releases/latest + 语义版本比较 + openExternal 走 Wails BrowserOpenURL);UpdateBanner 启动时 检查,有新版顶部横幅「前往下载」。404/限流/离线均静默不打扰。 注:未签名/公证(macOS Gatekeeper / Win SmartScreen 需后续证书);release workflow 需真实 tag push 才能验证(GitHub 侧运行)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 60 +++++++++++++++++++ sundynix-desktop/frontend/src/App.tsx | 2 + .../frontend/src/components/UpdateBanner.tsx | 38 ++++++++++++ sundynix-desktop/frontend/src/lib/version.ts | 50 ++++++++++++++++ 4 files changed, 150 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 sundynix-desktop/frontend/src/components/UpdateBanner.tsx create mode 100644 sundynix-desktop/frontend/src/lib/version.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b280160 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: Release + +# 打 vX.Y.Z 标签即触发:各平台用 wails 构建桌面端安装包 → 发布到 GitHub Release。 +# 用户旧版 App 启动时查 /releases/latest,发现新版即提示下载(见 frontend/src/lib/version.ts)。 +on: + push: + tags: ["v*"] + +permissions: + contents: write # 创建 release + 上传产物需要 + +jobs: + build: + name: 构建 ${{ matrix.label }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + label: macOS (universal) + platform: darwin/universal + - os: windows-latest + label: Windows (amd64) + platform: windows/amd64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: 安装 wails CLI + run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 + + - name: 构建桌面端(wails build;GOWORK=off 因 desktop 不在工作区) + working-directory: sundynix-desktop + env: + GOWORK: off + run: wails build -clean -platform ${{ matrix.platform }} + + # macOS:.app 是目录,打包成 zip 才能作为单文件资产分发。 + - name: 打包 macOS 产物 + if: runner.os == 'macOS' + working-directory: sundynix-desktop/build/bin + run: zip -r -y sundynix_desktop_macos_universal.zip sundynix_desktop.app + + - name: 重命名 Windows 产物 + if: runner.os == 'Windows' + working-directory: sundynix-desktop/build/bin + run: ren sundynix_desktop.exe sundynix_desktop_windows_amd64.exe + + - name: 发布到 GitHub Release(按 tag 创建/追加资产) + uses: softprops/action-gh-release@v2 + with: + fail_on_unmatched_files: false + files: | + sundynix-desktop/build/bin/sundynix_desktop_macos_universal.zip + sundynix-desktop/build/bin/sundynix_desktop_windows_amd64.exe diff --git a/sundynix-desktop/frontend/src/App.tsx b/sundynix-desktop/frontend/src/App.tsx index 4009511..7171ff2 100644 --- a/sundynix-desktop/frontend/src/App.tsx +++ b/sundynix-desktop/frontend/src/App.tsx @@ -12,6 +12,7 @@ import { RunsView } from "./views/RunsView"; import { Home } from "./views/Home"; import { Placeholder } from "./views/Placeholder"; import { CommandPalette, type Command } from "./components/CommandPalette"; +import { UpdateBanner } from "./components/UpdateBanner"; import { Login } from "./views/Login"; import { submitTask, streamTokens, streamExec, authMe, logout, type Identity, type AuthUser } from "./lib/api"; import type { TaskDsl } from "./lib/dsl"; @@ -158,6 +159,7 @@ export default function App() { className="pointer-events-none absolute inset-x-0 top-0 h-64 opacity-60" style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }} /> + setCmdOpen(true)} />
diff --git a/sundynix-desktop/frontend/src/components/UpdateBanner.tsx b/sundynix-desktop/frontend/src/components/UpdateBanner.tsx new file mode 100644 index 0000000..633c00b --- /dev/null +++ b/sundynix-desktop/frontend/src/components/UpdateBanner.tsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; +import { Download, X } from "lucide-react"; +import { checkUpdate, openExternal, type ReleaseInfo } from "../lib/version"; + +// 启动时查 GitHub Releases,有新版则在顶部显示一条可关闭的横幅,点击去下载页。 +export function UpdateBanner() { + const [rel, setRel] = useState(null); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + let alive = true; + void checkUpdate().then((r) => { + if (alive) setRel(r); + }); + return () => { + alive = false; + }; + }, []); + + if (!rel || dismissed) return null; + return ( +
+ + + 新版本 v{rel.version} 可用 + + + +
+ ); +} diff --git a/sundynix-desktop/frontend/src/lib/version.ts b/sundynix-desktop/frontend/src/lib/version.ts new file mode 100644 index 0000000..34d4348 --- /dev/null +++ b/sundynix-desktop/frontend/src/lib/version.ts @@ -0,0 +1,50 @@ +// 桌面端版本与"检查更新"——查 GitHub Releases 最新版,比当前版本新则提示下载。 +// 发版流程:bump 此处 APP_VERSION(= package.json version)→ 打 tag vX.Y.Z → release workflow 自动构建+发布。 + +export const APP_VERSION = "0.1.0"; // 当前桌面端版本(与 git tag 去掉 v 前缀对齐) + +const REPO = "blizzardzhang/sundynix-agentix"; + +export interface ReleaseInfo { + version: string; // 最新版本号(不含 v) + url: string; // release 页面(用户在此选 mac/win 安装包) + notes: string; // 更新日志 +} + +// checkUpdate 查 GitHub 最新 release;有比当前更新的版本则返回信息,否则 null(含出错/限流时静默)。 +export async function checkUpdate(): Promise { + try { + const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { + headers: { Accept: "application/vnd.github+json" }, + }); + if (!res.ok) return null; // 404=还没发过 release / 403=限流 → 静默 + const d = (await res.json()) as { tag_name?: string; html_url?: string; body?: string }; + const latest = String(d.tag_name ?? "").replace(/^v/, ""); + if (!latest || !isNewer(latest, APP_VERSION)) return null; + return { version: latest, url: d.html_url ?? `https://github.com/${REPO}/releases/latest`, notes: d.body ?? "" }; + } catch { + return null; // 离线等 → 不打扰 + } +} + +// openExternal 在系统浏览器打开链接(Wails 用 runtime.BrowserOpenURL,浏览器模式回退 window.open)。 +export function openExternal(url: string): void { + const w = window as unknown as { runtime?: { BrowserOpenURL?: (u: string) => void } }; + if (w.runtime?.BrowserOpenURL) { + w.runtime.BrowserOpenURL(url); + } else { + window.open(url, "_blank"); + } +} + +// isNewer 语义化版本比较:a 是否比 b 新(按 major.minor.patch)。 +function isNewer(a: string, b: string): boolean { + const pa = a.split(".").map((n) => parseInt(n, 10) || 0); + const pb = b.split(".").map((n) => parseInt(n, 10) || 0); + for (let i = 0; i < 3; i++) { + const x = pa[i] ?? 0; + const y = pb[i] ?? 0; + if (x !== y) return x > y; + } + return false; +} From 036ff00e4c4238edbe00148cb3670367ea4f6598 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 23 Jun 2026 16:47:22 +0800 Subject: [PATCH 13/15] =?UTF-8?q?docs:=20=E5=8F=91=E7=89=88=E6=B8=85?= =?UTF-8?q?=E5=8D=95=20RELEASE.md=EF=BC=88=E7=89=88=E6=9C=AC=E5=8F=B7?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=20+=20tag=20=E5=8F=91=E5=B8=83=20+=20?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=20+=20=E5=9B=9E=E6=BB=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- RELEASE.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 RELEASE.md diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..b6ab9dc --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,62 @@ +# 发版清单(桌面端 Release) + +打 `vX.Y.Z` 标签即触发 [`.github/workflows/release.yml`](.github/workflows/release.yml): +GitHub 用 macOS/Windows runner `wails build` 出安装包并发布到 **Releases**; +旧版用户的 App 启动时查 `releases/latest`,发现新版即弹横幅提示下载 +(逻辑见 [`version.ts`](sundynix-desktop/frontend/src/lib/version.ts))。 + +> 版本号一律用语义化 `主.次.补`(如 `0.1.1`);git tag 带 `v` 前缀(`v0.1.1`),App 内不带。 + +--- + +## 一、发版前:改版本号(必须三处对齐) + +| 文件 | 字段 | 作用 | +|---|---|---| +| `sundynix-desktop/frontend/src/lib/version.ts` | `APP_VERSION` | **功能性**:App 拿它和 GitHub 最新版比,决定是否提示更新 | +| `sundynix-desktop/frontend/package.json` | `version` | 前端包版本(保持一致,便于追溯) | + +> (`wails.json` 当前无版本字段;要在原生包元数据里带版本,可自行加 `info.productVersion`。) + +⚠️ **`APP_VERSION` 必须等于即将打的 tag(去掉 v)**,否则更新提示会错乱: +新包内嵌的 APP_VERSION = 新版本;旧用户 App 内是旧版本,比对才会提示。 + +## 二、发版步骤 + +```bash +# 1. 改完上面三处版本号,提交 +git add -A && git commit -m "release: v0.1.1" + +# 2. 先 push 代码(release 从被打标签的提交构建;同时触发 CI 回归) +git push origin main # 或当前分支 dev + +# 3. 打标签 + push 标签 → 触发 release 构建 +git tag -a v0.1.1 -m "v0.1.1:<一句话变更>" +git push origin v0.1.1 +``` + +## 三、验证 + +1. GitHub → **Actions** → 看 `Release` 工作流 macOS / Windows 两个 job 是否绿。 +2. GitHub → **Releases** → 确认 `v0.1.1` 下有两个资产: + - `sundynix_desktop_macos_universal.zip` + - `sundynix_desktop_windows_amd64.exe` +3. 用一台装了**旧版**的机器打开 App → 顶部应出现「新版本 v0.1.1 可用」横幅。 + +## 四、出错回滚 / 重发 + +```bash +# 删标签(本地 + 远程),改完重打 +git tag -d v0.1.1 +git push origin :refs/tags/v0.1.1 +# 修复后重新执行「二、发版步骤」 +``` + +## 五、注意事项 + +- **仓库需 public**:Release 资产要让终端用户直接下载;private 仓库的资产下载需 token,不适合分发。 +- **代码签名未做**(待办): + - macOS 未公证 → 用户首次打开被 Gatekeeper 拦,需「右键 → 打开」或系统设置放行。正式分发需 Apple 开发者证书 + `notarize`。 + - Windows 未签名 → SmartScreen 警告。需代码签名证书。 +- **GitHub API 限流**:更新检查未认证 60 次/小时/IP,按"每用户启动查一次"完全够用。 +- CI(`ci.yml`)在 push 到 `main`/`dev` 或 PR 时自动跑回归;Release 仅在 push `v*` 标签时触发。 From 616ba950b6e9a9cc381b0d376d4da05bf73a0452 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 23 Jun 2026 17:19:18 +0800 Subject: [PATCH 14/15] test: test --- PROGRESS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/PROGRESS.md b/PROGRESS.md index 07c57e3..af464f5 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -8,6 +8,9 @@ ## 第 1 层 · CLIENT(sundynix-desktop) + + + - [x] React 19 + TypeScript + Tailwind 工业级 UI(🟡 自建 UI primitives,未用 shadcn —— 与架构图有偏差) - [x] React Flow 编排画布 + JSON DSL 导出(含 branch 真/假边手柄) - [x] Wails 本地 Go 运行时 + TS/Go 强绑定 + 本地文件 I/O(另存为 / 系统打开 / 系统通知) From 6d839dbb30f446b189efda92c9e0f4dbd2900b75 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 23 Jun 2026 17:23:44 +0800 Subject: [PATCH 15/15] =?UTF-8?q?fix(ci):=20release.yml=20=E7=9A=84=20GOWO?= =?UTF-8?q?RK=20=E5=8A=A0=E5=BC=95=E5=8F=B7=EF=BC=88=E8=A3=B8=20off=20?= =?UTF-8?q?=E8=A2=AB=20YAML=20=E8=A7=A3=E6=9E=90=E4=B8=BA=E5=B8=83?= =?UTF-8?q?=E5=B0=94=E8=87=B4=20workflow=20=E4=B8=8D=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b280160..f6a32dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: - name: 构建桌面端(wails build;GOWORK=off 因 desktop 不在工作区) working-directory: sundynix-desktop env: - GOWORK: off + GOWORK: "off" # 必须加引号:YAML 裸 off 会被解析成布尔 false,使 workflow 校验不合法而不触发 run: wails build -clean -platform ${{ matrix.platform }} # macOS:.app 是目录,打包成 zip 才能作为单文件资产分发。