feat(agent): 多智能体协同 v1 —— coordinator 节点(Eino×Anthropic 融合)
LLM 自主在 agent 间路由/委派:orchestrator=ReAct(Eino 出机器),编排认知按 Anthropic orchestrator-worker 配方(出脑子)。专家=包成工具的子 agent(agent-as-tool),lead 给 每个专家写定制简报(brief)后并行派发、综合。方案见 MULTI_AGENT.md。 为什么 agent-as-tool 而非 Eino host:host 的 specialist 拿原始输入(preHandler return state.msgs),传不了 lead 写的定制简报,而定制简报正是 Anthropic 多智能体的 精髓。agent-as-tool 让 orchestrator 自己 emit 工具调用、参数 brief 即简报。 = OpenAI agent.as_tool() / Anthropic 研究系统的 orchestrator-worker。 - coordinator.go: specialistTool(react.Agent/ChatModel 包成 InvokableTool,入参 brief, 精炼返回) + parseSpecialists/buildSpecialists(带工具→react,不带→ChatModel,MCP 工具 按 spec.tools 过滤) + runCoordinator(lead 提示词=Anthropic 配方) + leadOrchestratorPrompt。 - 双路接入 execDSLNode(compose)+ runGraph(graph.go)的 case coordinator。 - 护栏:禁套娃(专家是内联叶子)/ MaxStep / 专家 I/O 计入共享 Budget / 降级(无 ToolCallingModel 或 0 专家 → runAgent)。 - streamAgentReply:抽出 runReactAgent 与 runCoordinator 共用的流式回流尾段。 - 复用即得:evaluator-optimizer=harness 低分纠偏;成本天花板=预算护栏;上下文隔离= 专家独立 react.Agent;观测=每次派发落 agent 轨迹。 测试:parseSpecialists / agent-as-tool 包装(brief 透传+精炼返回+失败作观察) / 降级。 live 验证(真 deepseek):两专家**并行派发**、lead 给各自写**不同定制简报**、最终 **综合**(非拼接)成稿,评测 1.00。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package eino
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/dsl"
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/harness"
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/llm"
|
||||
)
|
||||
|
||||
// TestParseSpecialists 钉死节点 config 的 agents 列表解析(含 tools 子集、无名跳过)。
|
||||
func TestParseSpecialists(t *testing.T) {
|
||||
cfg := map[string]any{"agents": []any{
|
||||
map[string]any{"name": "legal", "use": "法律条款", "system": "你是律师", "tools": []any{"wiki_search", "kb_search"}},
|
||||
map[string]any{"name": "finance", "use": "财务测算"},
|
||||
map[string]any{"use": "无名字应被跳过"},
|
||||
}}
|
||||
specs := parseSpecialists(cfg)
|
||||
if len(specs) != 2 {
|
||||
t.Fatalf("应解析出 2 个专家(无名跳过),got %d", len(specs))
|
||||
}
|
||||
if specs[0].Name != "legal" || specs[0].Use != "法律条款" || len(specs[0].Tools) != 2 {
|
||||
t.Fatalf("legal 解析有误: %+v", specs[0])
|
||||
}
|
||||
if specs[1].Name != "finance" || len(specs[1].Tools) != 0 {
|
||||
t.Fatalf("finance 解析有误: %+v", specs[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpecialistToolInvoke 钉死 agent-as-tool 包装:lead 写的 brief 透传给专家、结论回传、
|
||||
// 空 brief 兜底、专家失败作为"观察"返回而非中断协调。
|
||||
func TestSpecialistToolInvoke(t *testing.T) {
|
||||
var gotBrief string
|
||||
st := &specialistTool{
|
||||
name: "legal", tr: &execTracer{},
|
||||
info: &schema.ToolInfo{Name: "legal"},
|
||||
run: func(_ context.Context, brief string) (string, error) { gotBrief = brief; return "结论:条款合规", nil },
|
||||
}
|
||||
|
||||
out, err := st.InvokableRun(context.Background(), `{"brief":"审查合同第3条违约金"}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotBrief != "审查合同第3条违约金" {
|
||||
t.Fatalf("lead 简报应透传给专家,got %q", gotBrief)
|
||||
}
|
||||
if out != "结论:条款合规" {
|
||||
t.Fatalf("专家结论应回传,got %q", out)
|
||||
}
|
||||
|
||||
// 空 brief → 兜底文案(不把空串丢给专家)。
|
||||
st.run = func(_ context.Context, brief string) (string, error) { gotBrief = brief; return "ok", nil }
|
||||
_, _ = st.InvokableRun(context.Background(), `{}`)
|
||||
if !strings.Contains(gotBrief, "未给简报") {
|
||||
t.Fatalf("空 brief 应兜底,got %q", gotBrief)
|
||||
}
|
||||
|
||||
// 专家失败 → 返回失败观察,err 为 nil(不中断 orchestrator 的 ReAct 循环)。
|
||||
st.run = func(_ context.Context, _ string) (string, error) { return "", fmt.Errorf("boom") }
|
||||
out, err = st.InvokableRun(context.Background(), `{"brief":"x"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("专家失败不应上抛 error(应作观察返回): %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "执行失败") {
|
||||
t.Fatalf("应返回失败观察,got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunCoordinatorDegrade 钉死降级:模型不支持函数调用(ToolCallingModel=nil)→ 退回普通对话出稿,
|
||||
// 不因"多智能体"而卡死或空答。
|
||||
func TestRunCoordinatorDegrade(t *testing.T) {
|
||||
fs := &fakeSink{}
|
||||
o := &Orchestrator{
|
||||
pool: &fakeLLM{ready: true, stream: func([]llm.ChatMessage) string { return "降级直答" }},
|
||||
breaker: harness.NewCircuitBreaker(),
|
||||
sink: fs,
|
||||
}
|
||||
b := &board{query: "你好"}
|
||||
n := dsl.Node{ID: "c", Kind: "coordinator", Config: map[string]any{
|
||||
"agents": []any{map[string]any{"name": "x", "use": "y"}},
|
||||
}}
|
||||
o.runCoordinator(context.Background(), "t_co", b, "", n, &execTracer{}, "coordinator:c")
|
||||
if !strings.Contains(b.answer, "降级直答") {
|
||||
t.Fatalf("ToolCallingModel 缺失应降级 runAgent 出答案,got %q", b.answer)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user