c2811e79a3
现象:编排里并排三个 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>
152 lines
5.1 KiB
Go
152 lines
5.1 KiB
Go
package eino
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"strings"
|
||
"sync"
|
||
"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"
|
||
"github.com/sundynix/sundynix-shared/contract"
|
||
)
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
// TestComposeConversation 验证对话主流程走 compose 路径:模型输出流式回流到 sink 并累计成稿。
|
||
func TestComposeConversation(t *testing.T) {
|
||
fs := &fakeSink{}
|
||
o := &Orchestrator{
|
||
pool: &fakeLLM{ready: true, cm: &stubModel{reply: "我是 compose 路径的回答"}},
|
||
breaker: harness.NewCircuitBreaker(),
|
||
sink: fs,
|
||
}
|
||
b := &board{query: "你好"}
|
||
o.runComposeConversation(context.Background(), "task_compose", b, "", &execTracer{}, "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())
|
||
}
|
||
}
|
||
|
||
// TestComposeConversationDegradesToRunAgent 验证无 Eino ChatModel 时降级回 runAgent(流式回流不丢)。
|
||
func TestComposeConversationDegradesToRunAgent(t *testing.T) {
|
||
fs := &fakeSink{}
|
||
o := &Orchestrator{
|
||
// cm 为 nil → ChatModel() 返回 nil → runComposeConversation 降级 runAgent(走 stream 桩)。
|
||
pool: &fakeLLM{ready: true, stream: func([]llm.ChatMessage) string { return "降级路径回答" }},
|
||
breaker: harness.NewCircuitBreaker(),
|
||
sink: fs,
|
||
}
|
||
b := &board{query: "你好"}
|
||
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)
|
||
}
|
||
}
|