2d5b72930a
buildSpecialists 把专家名原样当 OpenAI function-calling 的 tools[].function.name, 而该字段受约束 ^[a-zA-Z0-9_-]+$。中文命名专家(中文产品里最自然的用法,如"条款专家") 会让模型直接 400: Invalid 'tools[0].function.name': string does not match pattern 整个协调节点挂掉,再沿 failover 链把备用模型也拖垮。 修:toolFuncName() 规范化给模型看的函数名——非法字符→下划线,清空→expert_N, 同批内撞名加序号保证唯一;展示名与执行轨迹(agent:<原名>)仍用原名不变。 模型靠 Desc(spec.Use) 判断何时调用,函数名不承载语义,故退化命名不影响派发质量。 实测(真 deepseek):修前中文名必现 tools[0].function.name 400;修后该 400 归零。 补 TestToolFuncName 覆盖:纯中文/合法ASCII/混合名/撞名唯一性。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
116 lines
4.4 KiB
Go
116 lines
4.4 KiB
Go
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)
|
||
}
|
||
}
|
||
|
||
// 专家名常是中文,直接当 LLM 函数名会被 OpenAI 兼容 API 400 拒绝(^[a-zA-Z0-9_-]+$),
|
||
// 整个多智能体协调会挂。toolFuncName 负责规范化 + 保证唯一。
|
||
func TestToolFuncName(t *testing.T) {
|
||
used := map[string]bool{}
|
||
if got := toolFuncName("条款专家", 0, used); got != "expert_1" {
|
||
t.Errorf("纯中文名应退化为 expert_1,got %q", got)
|
||
}
|
||
if got := toolFuncName("clause_expert", 1, used); got != "clause_expert" {
|
||
t.Errorf("合法 ASCII 名应原样保留,got %q", got)
|
||
}
|
||
if got := toolFuncName("法务expert", 2, used); got != "expert" {
|
||
t.Errorf("混合名应剥出 ASCII 部分,got %q", got)
|
||
}
|
||
// 与上一个 "expert" 撞名 → 加序号保证唯一
|
||
if got := toolFuncName("风控expert", 3, used); got != "expert_4" {
|
||
t.Errorf("撞名应加序号,got %q", got)
|
||
}
|
||
for _, n := range []string{"expert_1", "clause_expert", "expert", "expert_4"} {
|
||
if !used[n] {
|
||
t.Errorf("%q 应已登记进 used", n)
|
||
}
|
||
}
|
||
}
|