fix(desktop,dispatcher): 从编排执行的多 agent 图看不到「团队」tab
现象:编排里并排三个 agent(研究/撰写/审查)跑完,运行页没有团队 tab。 两处卡住,不是一处: 1) isMultiAgent 只认 coordinator: 节点。用户自己在图里并排多个 agent 也是 团队,却被整个漏掉。改成:有协调者,或 ≥2 个 agent: 节点。 2) 就算放宽 1,deriveTeam 仍按 kind==="agent" 挑工位——而两条产生 agent 的 路径 kind 并不一致:协调者派发的专家是 kind=agent,图里的 agent 节点是 kind=model。改成按节点名前缀(agent:/tool:)判,这本来就是后端一直遵守的 约定;顺带天然把 retriever:/map:/render: 这些同为 kind=tool 的节点挡在 工位之外(之前它们会混进来当工位)。 连带修一个更要命的:runAgent 把轨迹标签写死成"模型流式推理"、runReactAgent 写死成"ReAct 智能体(自主调工具)",用户在编排里给节点起的名字(研究 Agent / 撰写 Agent / 审查 Agent)整个丢了。后果不止办公室:执行轨迹里三行同名,根本 分不出谁是谁;团队视图只能退回节点 ID,工位显示成 r/w/rev。 改成一律 labelOf(n, 兜底) 由调用方传入,+2 单测钉住。 另:没有协调者时不再凭空画一个"协调者"小人(白板改挂「任务产出」),也不演 递简报那一程(没人可递),✓ 气泡改为收工即冒。 注意:已存的历史轨迹是落库的,仍是旧标签;只有新跑的任务才有节点名。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -66,7 +66,7 @@ func (o *Orchestrator) execComposeGraph(ctx context.Context, t *contract.Task, t
|
||||
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")
|
||||
o.runComposeConversation(ctx, t.ID, b, plan.System, tr, "agent", "模型流式推理")
|
||||
return b.answer, refsOf(b), b.fatalErr // 模型失败 → 上抛判 failed(对齐 graph.go)
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ func (o *Orchestrator) execComposeGraph(ctx context.Context, t *contract.Task, t
|
||||
if cerr != nil {
|
||||
// 编译失败(罕见)→ 降级为单轮对话兜底(自研 graph.go 已退役,不再回退)。
|
||||
tr.info("task", "system", "compose 编译失败", "降级单轮对话:"+cerr.Error())
|
||||
o.runComposeConversation(ctx, t.ID, b, plan.System, tr, "agent")
|
||||
o.runComposeConversation(ctx, t.ID, b, plan.System, tr, "agent", "模型流式推理")
|
||||
return b.answer, refsOf(b), b.fatalErr
|
||||
}
|
||||
// checkpoint id = task id:审批中断时 compose 据此把整图状态(含 board)落进 store。
|
||||
@@ -253,7 +253,7 @@ func (o *Orchestrator) execComposeGraph(ctx context.Context, t *contract.Task, t
|
||||
|
||||
// 图里无 agent 节点(纯工具/检索图)也要出一段答复。
|
||||
if fb.answer == "" {
|
||||
o.runComposeConversation(ctx, t.ID, fb, plan.System, tr, "agent")
|
||||
o.runComposeConversation(ctx, t.ID, fb, plan.System, tr, "agent", "模型流式推理")
|
||||
if fb.fatalErr != nil { // 兜底对话也可能触预算顶 / 模型失败
|
||||
return fb.answer, nil, fb.fatalErr
|
||||
}
|
||||
@@ -289,7 +289,7 @@ func (o *Orchestrator) execDSLNode(ctx context.Context, t *contract.Task, n dsl.
|
||||
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)
|
||||
o.runAgent(ctx, t.ID, b, sys, tr, "agent:"+n.ID, labelOf(n, "智能体"))
|
||||
}
|
||||
case "coordinator": // 多智能体协调:orchestrator 自主把子任务派给专家(agent-as-tool)再综合
|
||||
o.runCoordinator(ctx, t.ID, b, firstNonEmpty(cstr(n.Config, "system"), plan.System), n, tr, "coordinator:"+n.ID)
|
||||
|
||||
@@ -16,17 +16,17 @@ import (
|
||||
// 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) {
|
||||
func (o *Orchestrator) runComposeConversation(ctx context.Context, taskID string, b *board, system string, tr *execTracer, node, label string) {
|
||||
cm := o.pool.ChatModel()
|
||||
if cm == nil {
|
||||
o.runAgent(ctx, taskID, b, system, tr, node) // 无模型 → 降级桩
|
||||
o.runAgent(ctx, taskID, b, system, tr, node, label) // 无模型 → 降级桩
|
||||
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)
|
||||
o.runAgent(ctx, taskID, b, system, tr, node, label)
|
||||
return
|
||||
}
|
||||
_ = g.AddEdge(compose.START, "model")
|
||||
@@ -34,7 +34,7 @@ func (o *Orchestrator) runComposeConversation(ctx context.Context, taskID string
|
||||
r, err := g.Compile(ctx)
|
||||
if err != nil {
|
||||
tr.info(node, "system", "compose 降级", "编译失败,退回自研路径:"+err.Error())
|
||||
o.runAgent(ctx, taskID, b, system, tr, node)
|
||||
o.runAgent(ctx, taskID, b, system, tr, node, label)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ package eino
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/harness"
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/llm"
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
)
|
||||
|
||||
// stubModel 是实现 Eino model.BaseChatModel 的测试桩,固定回一段文本(确定性)。
|
||||
@@ -59,7 +62,7 @@ func TestComposeConversation(t *testing.T) {
|
||||
sink: fs,
|
||||
}
|
||||
b := &board{query: "你好"}
|
||||
o.runComposeConversation(context.Background(), "task_compose", b, "", &execTracer{}, "agent")
|
||||
o.runComposeConversation(context.Background(), "task_compose", b, "", &execTracer{}, "agent", "模型流式推理")
|
||||
|
||||
if !strings.Contains(b.answer, "compose 路径") {
|
||||
t.Fatalf("成稿未含模型输出: %q", b.answer)
|
||||
@@ -79,8 +82,70 @@ func TestComposeConversationDegradesToRunAgent(t *testing.T) {
|
||||
sink: fs,
|
||||
}
|
||||
b := &board{query: "你好"}
|
||||
o.runComposeConversation(context.Background(), "task_degrade", b, "", &execTracer{}, "agent")
|
||||
o.runComposeConversation(context.Background(), "task_degrade", b, "", &execTracer{}, "agent", "模型流式推理")
|
||||
if !strings.Contains(b.answer, "降级路径") {
|
||||
t.Fatalf("无 ChatModel 应降级 runAgent 出稿: %q", b.answer)
|
||||
}
|
||||
}
|
||||
|
||||
// 图里可以并排好几个 agent(研究/撰写/审查)。之前 runAgent 把轨迹标签写死成
|
||||
// "模型流式推理",用户在编排里起的节点名整个丢了 —— 轨迹里三行同名,团队视图里
|
||||
// 工位只能退回节点 ID(r/w/rev),谁是谁全靠猜。这里钉住:标签必须来自节点 label。
|
||||
type execCapture struct {
|
||||
mu sync.Mutex
|
||||
buf [][]byte
|
||||
}
|
||||
|
||||
func (c *execCapture) PublishExec(_ string, data []byte) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.buf = append(c.buf, append([]byte(nil), data...))
|
||||
return nil
|
||||
}
|
||||
func (c *execCapture) CompleteExec(string) error { return nil }
|
||||
func (c *execCapture) labels() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var out []string
|
||||
for _, b := range c.buf {
|
||||
var e contract.ExecEvent
|
||||
if json.Unmarshal(b, &e) == nil {
|
||||
out = append(out, e.Label)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRunAgentUsesNodeLabel(t *testing.T) {
|
||||
cap := &execCapture{}
|
||||
o := &Orchestrator{
|
||||
pool: &fakeLLM{ready: true, cm: &stubModel{reply: "要点若干"}},
|
||||
breaker: harness.NewCircuitBreaker(),
|
||||
sink: &fakeSink{},
|
||||
exec: cap,
|
||||
}
|
||||
o.runAgent(context.Background(), "task_label", &board{query: "选型"}, "", o.tracer("task_label"), "agent:r", "研究 Agent")
|
||||
|
||||
got := strings.Join(cap.labels(), "|")
|
||||
if !strings.Contains(got, "研究 Agent") {
|
||||
t.Fatalf("轨迹标签应为节点名「研究 Agent」,实际: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "模型流式推理") {
|
||||
t.Fatalf("不该再出现写死的兜底标签: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentLabelFallback(t *testing.T) {
|
||||
cap := &execCapture{}
|
||||
o := &Orchestrator{
|
||||
pool: &fakeLLM{ready: true, cm: &stubModel{reply: "x"}},
|
||||
breaker: harness.NewCircuitBreaker(),
|
||||
sink: &fakeSink{},
|
||||
exec: cap,
|
||||
}
|
||||
o.runAgent(context.Background(), "task_fb", &board{query: "q"}, "", o.tracer("task_fb"), "agent:x", "")
|
||||
|
||||
if got := strings.Join(cap.labels(), "|"); !strings.Contains(got, "模型流式推理") {
|
||||
t.Fatalf("节点没起名时应回兜底标签: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,13 +222,13 @@ func (o *Orchestrator) runCoordinator(ctx context.Context, taskID string, b *boa
|
||||
tcm := o.pool.ToolCallingModel()
|
||||
if tcm == nil || len(specs) == 0 {
|
||||
tr.info(node, "system", "协调者降级", "模型不支持函数调用或未配置专家,退回普通对话")
|
||||
o.runAgent(ctx, taskID, b, system, tr, node)
|
||||
o.runAgent(ctx, taskID, b, system, tr, node, labelOf(n, "多智能体协调"))
|
||||
return
|
||||
}
|
||||
specialists := o.buildSpecialists(ctx, specs, b, taskID, tr)
|
||||
if len(specialists) == 0 {
|
||||
tr.info(node, "system", "协调者降级", "无可用专家,退回普通对话")
|
||||
o.runAgent(ctx, taskID, b, system, tr, node)
|
||||
o.runAgent(ctx, taskID, b, system, tr, node, labelOf(n, "多智能体协调"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ func (o *Orchestrator) runCoordinator(ctx context.Context, taskID string, b *boa
|
||||
})
|
||||
if err != nil {
|
||||
tr.emit(node, "model", "error", "构建协调者", err.Error(), 0)
|
||||
o.runAgent(ctx, taskID, b, system, tr, node)
|
||||
o.runAgent(ctx, taskID, b, system, tr, node, labelOf(n, "多智能体协调"))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,10 @@ 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) {
|
||||
// label 是这个节点在轨迹里的显示名,一律由调用方按 labelOf(n, 兜底) 传入:
|
||||
// 图里可以并排好几个 agent,写死成同一个名字的话轨迹/团队视图里根本分不出谁是谁。
|
||||
func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, system string, tr *execTracer, node, label string) {
|
||||
label = firstNonEmpty(label, "模型流式推理")
|
||||
rc := &RunCtx{
|
||||
System: firstNonEmpty(system, defaultAgentSystem),
|
||||
Query: b.query,
|
||||
@@ -155,7 +158,7 @@ func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, sy
|
||||
return
|
||||
}
|
||||
}
|
||||
tr.emit(node, "model", "start", "模型流式推理", "", 0)
|
||||
tr.emit(node, "model", "start", label, "", 0)
|
||||
t0 := time.Now()
|
||||
n := 0
|
||||
var produced strings.Builder // 本节点自身产出(用于沿图向下游传递)
|
||||
@@ -184,7 +187,7 @@ func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, sy
|
||||
tr.info(node, "model", "推理过程", fmt.Sprintf("思考 %d 字:%s", len([]rune(rc)), truncate(rc, 200)))
|
||||
}
|
||||
if err != nil {
|
||||
tr.emit(node, "model", "error", "模型流式推理", err.Error(), time.Since(t0).Milliseconds())
|
||||
tr.emit(node, "model", "error", label, err.Error(), time.Since(t0).Milliseconds())
|
||||
// 未产出任何 token 即失败 → 标记致命错,让任务判 failed(暴露原因,便于监控告警),
|
||||
// 而非静默 done-空。已流出部分 token 的中断也算失败(结果不完整)。
|
||||
if b.fatalErr == nil {
|
||||
@@ -200,7 +203,7 @@ func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, sy
|
||||
tr.info(node, "system", "输出护栏", fmt.Sprintf("已脱敏 %d 处疑似密钥/PII", red.Hits()))
|
||||
}
|
||||
o.recordAgentOutput(b, produced.String()) // 产出入黑板:成当前成稿 + 供下游接力
|
||||
tr.emit(node, "model", "end", "模型流式推理",
|
||||
tr.emit(node, "model", "end", label,
|
||||
fmt.Sprintf("%d tokens / %d 字", n, len([]rune(produced.String()))), time.Since(t0).Milliseconds())
|
||||
}
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *boar
|
||||
tools := o.agentTools(b, taskID, tr)
|
||||
if tcm == nil || len(tools) == 0 {
|
||||
tr.info(node, "system", "ReAct 降级", "模型不支持函数调用或无可用工具,退回普通对话")
|
||||
o.runAgent(ctx, taskID, b, system, tr, node)
|
||||
o.runAgent(ctx, taskID, b, system, tr, node, labelOf(n, "ReAct 智能体"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *boar
|
||||
})
|
||||
if err != nil {
|
||||
tr.emit(node, "model", "error", "构建 ReAct 智能体", err.Error(), 0)
|
||||
o.runAgent(ctx, taskID, b, system, tr, node)
|
||||
o.runAgent(ctx, taskID, b, system, tr, node, labelOf(n, "ReAct 智能体"))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user