diff --git a/services/cognida-go/go.mod b/services/cognida-go/go.mod index a7f18cb..65e673a 100644 --- a/services/cognida-go/go.mod +++ b/services/cognida-go/go.mod @@ -1,6 +1,6 @@ module cognida -go 1.25.12 +go 1.25.13 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 diff --git a/services/cognida-go/internal/service/agent/framework/e2e_regression_test.go b/services/cognida-go/internal/service/agent/framework/e2e_regression_test.go new file mode 100644 index 0000000..ecff0e1 --- /dev/null +++ b/services/cognida-go/internal/service/agent/framework/e2e_regression_test.go @@ -0,0 +1,344 @@ +package framework + +// 端到端回归(issue #4 / #8):不依赖任何外部服务,全进程内自洽。 +// +// - #8 走真实 wire 链路:httptest 起 OpenAI 兼容 mock 服务 → 生产 chat 客户端 +// (NewToolCallingChatModel,含 DSML 归一化装饰)→ 生产 Builder/Agent → 生产 +// RegistryAgentOrchestrator(Chat 与 SSE Stream 双路径)。mock 持续返回参数为非法 +// JSON 的工具调用,验证自我修复护栏在 wire 级真实生效(4 次即提前收尾,而非空转到 maxIter)。 +// - #4 走 orchestrator 级:模型桩在工具轮后返回 (nil, nil)(OpenAI/Ollama 客户端会把 +// 异常转为 error,该分支防御的是其它/未来 provider),验证经生产编排器后用户仍拿到 +// 非空 wind-down 结论而非空答。 + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/cloudwego/eino/schema" + + "cognida/internal/infrastructure/llm/chat" +) + +// ======================================== +// mock OpenAI 兼容服务 +// ======================================== + +// wireMessage/wireRequest 是 OpenAI wire 协议请求侧的最小解码集(仅测试断言所需字段)。 +type wireMessage struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCallID string `json:"tool_call_id"` + ToolCalls []struct { + ID string `json:"id"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` +} + +type wireRequest struct { + Model string `json:"model"` + Messages []wireMessage `json:"messages"` + Stream bool `json:"stream"` +} + +// mockOpenAI 是可脚本的 OpenAI 兼容服务:非 wind-down 请求一律返回「参数为非法 JSON 的 +// 工具调用」;末条消息为 wind-down 指令(System)时返回正常收尾内容。记录全部请求供断言。 +type mockOpenAI struct { + mu sync.Mutex + requests []wireRequest + t *testing.T +} + +const ( + malformedTool = "query_data" + malformedArgs = `{"sql": "SELECT` + windDownContent = "已基于现有观察给出部分结论。" + streamFinalText = "流式收尾结论" + windDownMarker = "请勿再调用任何工具" + nonStreamFinalJSD = `{"id":"m","object":"chat.completion","created":1,"model":"mock",` + + `"choices":[{"index":0,"message":{"role":"assistant","content":"` + windDownContent + `"},` + + `"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":20,"total_tokens":120}}` +) + +func toolCallRespJSON(callID int) string { + return fmt.Sprintf(`{"id":"m","object":"chat.completion","created":1,"model":"mock",`+ + `"choices":[{"index":0,"message":{"role":"assistant","content":"","tool_calls":[`+ + `{"id":"call_%d","type":"function","function":{"name":"%s","arguments":%s}}]},`+ + `"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":50,"completion_tokens":5,"total_tokens":55}}`, + callID, malformedTool, mustJSONString(malformedArgs)) +} + +// mustJSONString 把任意字符串编码为 JSON 字符串字面量(保留非法 JSON 原样转义)。 +func mustJSONString(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +func (m *mockOpenAI) ServeHTTP(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req wireRequest + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + m.mu.Lock() + m.requests = append(m.requests, req) + n := len(m.requests) + m.mu.Unlock() + + isWindDown := false + if len(req.Messages) > 0 { + last := req.Messages[len(req.Messages)-1] + isWindDown = last.Role == "system" && strings.Contains(last.Content, windDownMarker) + } + + if req.Stream { + w.Header().Set("Content-Type", "text/event-stream") + var chunks []string + if isWindDown { + chunks = []string{ + sseChunk(`{"role":"assistant","content":`+mustJSONString(streamFinalText)+`}`, ""), + sseChunk(`{}`, "stop"), + } + } else { + chunks = []string{ + sseChunk(`{"role":"assistant","tool_calls":[{"index":0,"id":"call_s`+fmt.Sprint(n)+ + `","type":"function","function":{"name":"`+malformedTool+`","arguments":`+mustJSONString(malformedArgs)+`}}]}`, ""), + sseChunk(`{}`, "tool_calls"), + } + } + for _, c := range chunks { + fmt.Fprint(w, "data: "+c+"\n\n") + } + fmt.Fprint(w, "data: [DONE]\n\n") + return + } + + w.Header().Set("Content-Type", "application/json") + if isWindDown { + fmt.Fprint(w, nonStreamFinalJSD) + return + } + fmt.Fprint(w, toolCallRespJSON(n)) +} + +func sseChunk(delta, finishReason string) string { + fr := "null" + if finishReason != "" { + fr = mustJSONString(finishReason) + } + return `{"id":"m","object":"chat.completion.chunk","created":1,"model":"mock",` + + `"choices":[{"index":0,"delta":` + delta + `,"finish_reason":` + fr + `}]}` +} + +func (m *mockOpenAI) requestCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.requests) +} + +func (m *mockOpenAI) requestAt(i int) wireRequest { + m.mu.Lock() + defer m.mu.Unlock() + return m.requests[i] +} + +// newWireTestAgent 构造「生产 chat 客户端 + 生产 Builder」的真实 Agent。 +func newWireTestAgent(t *testing.T, baseURL string, toolInvocations *[]string) Agent { + t.Helper() + tm, err := chat.NewToolCallingChatModel(context.Background(), &chat.ChatConfig{ + Source: "remote", + APIKey: "test-key", + BaseURL: baseURL, + ModelName: "mock-model", + Provider: "openai", + }) + if err != nil { + t.Fatalf("创建生产 chat 客户端失败: %v", err) + } + qtool := &recordingTool{name: malformedTool, calls: toolInvocations} + agent, err := New(tm). + WithToolModel(tm). + Name("e2e-wire"). + Prompt("你是测试助手"). + Tools(qtool). + WithMaxIterations(20). + Build(context.Background()) + if err != nil { + t.Fatalf("构建 Agent 失败: %v", err) + } + return agent +} + +// ======================================== +// #8:wire 级端到端(Chat 路径) +// ======================================== + +// TestE2E_MalformedArgs_WireLevelChat 验证:真实 wire 链路(HTTP→生产 chat 客户端→DSML 归一化 +// →Builder/Agent→execLoop)下,模型持续返回参数为非法 JSON 的工具调用时—— +// - 工具本体从不被执行(畸形参数不触达工具); +// - 自我修复护栏 4 次失败即提前收尾:恰好 windDownThreshold+1 次 HTTP 请求(修复前会空转到 maxIter); +// - wind-down 请求的历史里 assistant.tool_calls 与 tool 消息严格 1:1(4:4),观察均为解析失败合成错误, +// 且已注入带签名的再规划提示; +// - 最终响应 terminated_by=repair_exhausted、partial=true、内容非空。 +func TestE2E_MalformedArgs_WireLevelChat(t *testing.T) { + mock := &mockOpenAI{t: t} + server := httptest.NewServer(mock) + defer server.Close() + + var invoked []string + agent := newWireTestAgent(t, server.URL, &invoked) + + resp, err := agent.Chat(context.Background(), "统计用户数") + if err != nil { + t.Fatalf("Chat: %v", err) + } + + if len(invoked) != 0 { + t.Errorf("畸形参数绝不应触达工具执行, got %v", invoked) + } + if resp.Metadata["terminated_by"] != TerminatedByRepairExhausted { + t.Errorf("expected terminated_by=repair_exhausted, got %v", resp.Metadata["terminated_by"]) + } + if resp.Metadata["partial"] != true { + t.Error("护栏提前收尾应标记 partial") + } + if !strings.Contains(resp.Content, windDownContent[:6]) { + t.Errorf("应交付 wind-down 结论, got %q", resp.Content) + } + + // 请求次数 = 4 轮畸形工具调用 + 1 次 wind-down;修复前护栏失明,会一路空转到 maxIter=20。 + if got := mock.requestCount(); got != windDownThreshold+1 { + t.Fatalf("HTTP 请求数应为 %d(护栏 %d 次即收尾+wind-down), got %d", windDownThreshold+1, windDownThreshold, got) + } + + // 末次(wind-down)请求:1:1 配对 + 再规划提示注入 + 观察为合成解析错误。 + last := mock.requestAt(mock.requestCount() - 1) + assistantWithCalls, toolObs, replanNote := 0, 0, false + for _, msg := range last.Messages { + if msg.Role == "assistant" && len(msg.ToolCalls) > 0 { + assistantWithCalls++ + } + if msg.Role == "tool" { + toolObs++ + if !strings.Contains(msg.Content, "参数解析失败") { + t.Errorf("tool 观察应为合成解析失败错误, got %q", msg.Content) + } + } + if msg.Role == "system" && strings.Contains(msg.Content, "重复失败:"+malformedTool) { + replanNote = true + } + } + if assistantWithCalls != windDownThreshold || toolObs != windDownThreshold { + t.Errorf("wire 历史 tool_call/tool 消息应 %d:%d, got %d:%d", + windDownThreshold, windDownThreshold, assistantWithCalls, toolObs) + } + if !replanNote { + t.Error("wire 历史应含带签名的再规划提示(畸形参数计入护栏的旁证)") + } + // 每次记录的 ToolCall 均为解析失败。 + for _, tc := range resp.ToolCalls { + if tc.Error == nil || !strings.Contains(tc.Error.Error(), "invalid arguments") { + t.Errorf("每次工具调用应记录解析失败: %+v", tc) + } + } +} + +// ======================================== +// #8:wire 级端到端(SSE Stream + 生产编排器) +// ======================================== + +// TestE2E_MalformedArgs_WireLevelStream 验证流式链路(streamSink→RegistryAgentOrchestrator): +// 畸形参数同样计入护栏(4 次即收尾、恰 5 次 HTTP 请求),最终 content 事件为 wind-down 结论。 +func TestE2E_MalformedArgs_WireLevelStream(t *testing.T) { + mock := &mockOpenAI{t: t} + server := httptest.NewServer(mock) + defer server.Close() + + var invoked []string + agent := newWireTestAgent(t, server.URL, &invoked) + + orch := NewRegistryAgentOrchestrator(func(id string) (Agent, bool) { return agent, id == "e2e-wire" }) + + events, err := orch.ExecuteStream(context.Background(), "e2e-wire", "统计用户数") + if err != nil { + t.Fatalf("ExecuteStream: %v", err) + } + + var content strings.Builder + toolCallEvents := 0 + for ev := range events { + switch ev.Type { + case "tool_call": + toolCallEvents++ + case "content": + content.WriteString(ev.Content) + case "error": + t.Fatalf("不应发生 error 事件: %s", ev.Error) + } + } + + if len(invoked) != 0 { + t.Errorf("畸形参数绝不应触达工具执行, got %v", invoked) + } + // 注:parseErr 分支只发 tool_call 事件(不发 tool_result),且编排器把状态硬编码为 + // calling——属既有流式事件语义缺口(与 #3/#7 同族),不在此断言、留待对应 issue 收敛。 + if toolCallEvents != windDownThreshold { + t.Errorf("流式 tool_call 事件应恰 %d(护栏收尾的旁证), got %d", windDownThreshold, toolCallEvents) + } + if !strings.Contains(content.String(), streamFinalText) { + t.Errorf("流式最终内容应含 wind-down 结论, got %q", content.String()) + } + if got := mock.requestCount(); got != windDownThreshold+1 { + t.Fatalf("HTTP 请求数应为 %d, got %d", windDownThreshold+1, got) + } +} + +// ======================================== +// #4:orchestrator 级端到端 +// ======================================== + +// TestE2E_NilResponseAfterTools_Orchestrator 验证:经生产编排器(RegistryAgentOrchestrator.Execute) +// 执行时,模型桩在工具轮后返回 (nil, nil)(异常空响应)——用户仍拿到非空 wind-down 结论, +// 而非修复前的空字符串。 +func TestE2E_NilResponseAfterTools_Orchestrator(t *testing.T) { + var invoked []string + qtool := &recordingTool{name: "query", calls: &invoked} + + tm := &scriptedToolModel{script: []*schema.Message{ + toolCallMsg("1", "query"), + nil, // 工具轮后异常空响应 + }} + agent, err := New(tm). + WithToolModel(tm). + Name("e2e-nil"). + Prompt("你是测试助手"). + Tools(qtool). + WithMaxIterations(10). + Build(context.Background()) + if err != nil { + t.Fatalf("构建 Agent 失败: %v", err) + } + + orch := NewRegistryAgentOrchestrator(func(id string) (Agent, bool) { return agent, id == "e2e-nil" }) + + result, err := orch.Execute(context.Background(), "e2e-nil", "查一下") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(result, "final") { + t.Fatalf("nil 响应(已跑过工具轮)经编排器应交付 wind-down 结论, got %q", result) + } + if len(invoked) != 1 { + t.Errorf("query 工具应恰执行一次, got %v", invoked) + } +} diff --git a/services/cognida-go/internal/service/agent/framework/eino_nil_and_malformed_test.go b/services/cognida-go/internal/service/agent/framework/eino_nil_and_malformed_test.go new file mode 100644 index 0000000..4e3e17f --- /dev/null +++ b/services/cognida-go/internal/service/agent/framework/eino_nil_and_malformed_test.go @@ -0,0 +1,331 @@ +package framework + +import ( + "context" + "strings" + "testing" + + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/schema" +) + +// 本文件覆盖两处回归: +// - issue #4:execLoop 中 msg==nil 的处理——已跑过工具轮后不得按自然结束交付空答; +// - issue #8:handleToolCall 参数不可解析分支须计入自我修复护栏。 + +// badToolCallMsg 构造一个参数为非法 JSON 的工具调用 assistant 响应(issue #8 场景)。 +func badToolCallMsg(id, name string) *schema.Message { + return &schema.Message{ + Role: schema.Assistant, + ToolCalls: []schema.ToolCall{{ + ID: id, + Function: schema.FunctionCall{Name: name, Arguments: `{"sql": "SELECT`}, + }}, + } +} + +// ======================================== +// issue #4:msg == nil 的两种走向 +// ======================================== + +// TestReAct_NilResponseAfterToolsWindsDown 验证:已跑过工具轮后某轮生成返回 (nil, nil) +// (既无消息也无错误)时,不得按自然结束交付空答——应视作异常终止转 wind-down, +// 从已有观察合成答复,并标注 terminated_by=no_response、partial=true。 +// 脚本:第 1 轮正常工具调用(产出观察)→ 第 2 轮 nil → 第 3 次生成(wind-down)走脚本默认 "final"。 +func TestReAct_NilResponseAfterToolsWindsDown(t *testing.T) { + var order []string + qtool := &recordingTool{name: "query", calls: &order} + + // 脚本槽位为 nil 即让 scriptedToolModel.Generate 返回 (nil, nil)。 + tm := &scriptedToolModel{script: []*schema.Message{ + toolCallMsg("1", "query"), + nil, + }} + + a := &agentImpl{ + name: "nilresp", + toolModel: tm, + prompt: "p", + tools: []tool.BaseTool{qtool}, + maxIter: 10, + } + + resp, err := a.Chat(context.Background(), "工具后空响应") + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.Content != "final" { + t.Errorf("nil 响应(已跑过工具轮)必须 wind-down 出非空结论, got %q", resp.Content) + } + if resp.Metadata["terminated_by"] != TerminatedByNoResponse { + t.Errorf("expected terminated_by=no_response, got %v", resp.Metadata["terminated_by"]) + } + if resp.Metadata["partial"] != true { + t.Errorf("wind-down 恢复必须标记 partial") + } + // query(观察)+ nil 响应 + wind-down 合成 = 3 次生成;工具只真正执行一次。 + if tm.calls != 3 { + t.Errorf("expected 3 Generate calls (tool + nil + wind-down), got %d", tm.calls) + } + if len(order) != 1 || order[0] != "query" { + t.Errorf("expected query invoked exactly once, got %v", order) + } +} + +// TestReAct_NilResponseFirstRound_StaysNatural 验证:首轮(i==0)即无消息时仍按自然结束放行 +// ——尚无任何工具观察可合成,wind-down 无从谈起(与 empty_finish 的 i>0 门槛同理)。 +// 这是与上一用例互补的另一半:只在「有观察」时才兜底。 +func TestReAct_NilResponseFirstRound_StaysNatural(t *testing.T) { + var order []string + qtool := &recordingTool{name: "query", calls: &order} + + tm := &scriptedToolModel{script: []*schema.Message{nil}} + + a := &agentImpl{ + name: "nilresp", + toolModel: tm, + prompt: "p", + tools: []tool.BaseTool{qtool}, + maxIter: 10, + } + + resp, err := a.Chat(context.Background(), "首轮即空响应") + if err != nil { + t.Fatalf("Chat: %v", err) + } + if _, ok := resp.Metadata["terminated_by"]; ok { + t.Errorf("首轮 nil 响应不应设置 terminated_by: %v", resp.Metadata) + } + if resp.Metadata["partial"] == true { + t.Errorf("首轮 nil 响应不应标记 partial") + } + // 仅 1 次生成;无观察不触发 wind-down;工具从未被调用。 + if tm.calls != 1 { + t.Errorf("expected exactly 1 Generate call, got %d", tm.calls) + } + if len(order) != 0 { + t.Errorf("expected no tool invocation, got %v", order) + } +} + +// TestReAct_NilResponseAfterTruncationRetry_StaysNatural 验证:i>0 不等于「有观察」—— +// 截断重试轮(注入精简提示后 continue)也消耗迭代但不产生任何工具观察。此路径上 nil 响应 +// 仍按自然结束放行:wind-down 的「从观察给出结论」在零观察下会诱导模型凭空编造(幻觉)。 +func TestReAct_NilResponseAfterTruncationRetry_StaysNatural(t *testing.T) { + var order []string + qtool := &recordingTool{name: "query", calls: &order} + + // 第 1 轮 finish_reason=length 且无工具调用(触发注入精简提示后重试),第 2 轮 nil。 + truncated := &schema.Message{ + Role: schema.Assistant, + Content: "半句", + ResponseMeta: &schema.ResponseMeta{FinishReason: "length"}, + } + tm := &scriptedToolModel{script: []*schema.Message{truncated, nil}} + + a := &agentImpl{ + name: "nilresp", + toolModel: tm, + prompt: "p", + tools: []tool.BaseTool{qtool}, + maxIter: 10, + } + + resp, err := a.Chat(context.Background(), "截断后空响应") + if err != nil { + t.Fatalf("Chat: %v", err) + } + if _, ok := resp.Metadata["terminated_by"]; ok { + t.Errorf("零观察的 nil 响应不应设置 terminated_by: %v", resp.Metadata) + } + if resp.Metadata["partial"] == true { + t.Errorf("零观察的 nil 响应不应标记 partial") + } + // 截断轮 + nil 轮 = 2 次生成;无 wind-down;工具从未被调用。 + if tm.calls != 2 { + t.Errorf("expected 2 Generate calls (truncated + nil), got %d", tm.calls) + } + if len(order) != 0 { + t.Errorf("expected no tool invocation, got %v", order) + } +} + +// TestReAct_NilResponseWindDownAlsoEmpty_FallsBackToNotice 验证:工具轮后 nil 响应转 wind-down, +// 而收尾生成本身也失败(继续返回 nil)时,交付诚实的降级说明而非空白回复。 +func TestReAct_NilResponseWindDownAlsoEmpty_FallsBackToNotice(t *testing.T) { + var order []string + qtool := &recordingTool{name: "query", calls: &order} + + // 工具轮 → nil → wind-down 也 nil。 + tm := &scriptedToolModel{script: []*schema.Message{ + toolCallMsg("1", "query"), + nil, + nil, + }} + + a := &agentImpl{ + name: "nilresp", + toolModel: tm, + prompt: "p", + tools: []tool.BaseTool{qtool}, + maxIter: 10, + } + + resp, err := a.Chat(context.Background(), "收尾也失败") + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.Metadata["terminated_by"] != TerminatedByNoResponse { + t.Errorf("expected terminated_by=no_response, got %v", resp.Metadata["terminated_by"]) + } + if resp.Metadata["partial"] != true { + t.Error("异常终止应标记 partial") + } + if !strings.Contains(resp.Content, "未能生成最终答复") || !strings.Contains(resp.Content, TerminatedByNoResponse) { + t.Errorf("收尾失败应交付含终止原因的降级说明, got %q", resp.Content) + } + if tm.calls != 3 { + t.Errorf("expected 3 Generate calls (tool + nil + wind-down-nil), got %d", tm.calls) + } +} + +// ======================================== +// issue #8:参数不可解析须计入自我修复护栏 +// ======================================== + +// TestMalformedArgs_NeverReachTool 校验畸形参数不触达工具执行,且 assistant.tool_calls +// 与 tool 消息保持 1:1(观察为合成的解析失败错误)。 +func TestMalformedArgs_NeverReachTool(t *testing.T) { + var order []string + qtool := &recordingTool{name: "query", calls: &order} + + tm := &scriptedToolModel{script: []*schema.Message{ + badToolCallMsg("1", "query"), + }} + + a := &agentImpl{ + name: "badargs", + toolModel: tm, + prompt: "p", + tools: []tool.BaseTool{qtool}, + maxIter: 10, + } + + resp, err := a.Chat(context.Background(), "畸形参数") + if err != nil { + t.Fatalf("Chat: %v", err) + } + if len(order) != 0 { + t.Errorf("畸形参数绝不应触达工具执行, got %v", order) + } + if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Error == nil || + !strings.Contains(resp.ToolCalls[0].Error.Error(), "invalid arguments") { + t.Errorf("应记录一次解析失败的工具调用, got %+v", resp.ToolCalls) + } + // 1:1 配对:历史中 1 条带 tool_calls 的 assistant 消息对应 1 条 tool 观察消息。 + var assistantWithCalls, toolObs int + for _, msg := range tm.lastInput { + if msg.Role == schema.Assistant && len(msg.ToolCalls) > 0 { + assistantWithCalls++ + } + if msg.Role == schema.Tool && strings.Contains(msg.Content, "参数解析失败") { + toolObs++ + } + } + if assistantWithCalls != 1 || toolObs != 1 { + t.Errorf("tool_call 与 tool 消息应严格 1:1, got assistant=%d tool=%d", assistantWithCalls, toolObs) + } +} + +// TestMalformedArgs_RepeatedTriggersReplan 校验:同一工具的畸形参数失败累计达再规划阈值(2 次)后, +// 注入带签名的再规划提示(签名退化为工具名本身,kind 为空);未达 wind-down 阈值则继续并自然收尾。 +func TestMalformedArgs_RepeatedTriggersReplan(t *testing.T) { + var order []string + qtool := &recordingTool{name: "query", calls: &order} + + // 两轮畸形参数(触发再规划),脚本耗尽后第 3 次生成自然收尾 "final"。 + tm := &scriptedToolModel{script: []*schema.Message{ + badToolCallMsg("1", "query"), + badToolCallMsg("2", "query"), + }} + + a := &agentImpl{ + name: "badargs", + toolModel: tm, + prompt: "p", + tools: []tool.BaseTool{qtool}, + maxIter: 10, + } + + resp, err := a.Chat(context.Background(), "反复畸形参数") + if err != nil { + t.Fatalf("Chat: %v", err) + } + // 累计 2 次失败 < windDownThreshold(4):不应提前收尾,应自然收尾。 + if resp.Metadata["terminated_by"] == TerminatedByRepairExhausted { + t.Fatal("累计 2 次失败不应触发 wind-down") + } + // 再规划提示(SystemMessage,含退化签名「query」)应注入后续输入。 + var noted bool + for _, msg := range tm.lastInput { + if msg.Role == schema.System && strings.Contains(msg.Content, "重复失败:query") { + noted = true + break + } + } + if !noted { + t.Fatalf("畸形参数反复失败后应注入带签名的再规划提示, lastInput=%+v", tm.lastInput) + } + if len(order) != 0 { + t.Errorf("畸形参数绝不应触达工具执行, got %v", order) + } +} + +// TestMalformedArgs_WindDown 校验:模型永不收敛、持续以畸形参数调用同一工具时,护栏在 +// windDownThreshold(4) 次失败后触发提前收尾(terminated_by=repair_exhausted、partial=true)—— +// 修复前该失败模式对护栏不可见,会一直空转到脚本耗尽/自然收尾。 +func TestMalformedArgs_WindDown(t *testing.T) { + var order []string + qtool := &recordingTool{name: "query", calls: &order} + + // 10 轮畸形参数脚本:足够触发护栏(4 次)且足以在修复前暴露「空转」差异。 + script := make([]*schema.Message, 0, 10) + for i := 0; i < 10; i++ { + script = append(script, badToolCallMsg(string(rune('0'+i)), "query")) + } + tm := &scriptedToolModel{script: script} + + a := &agentImpl{ + name: "badargs", + toolModel: tm, + prompt: "p", + tools: []tool.BaseTool{qtool}, + maxIter: 20, + } + + resp, err := a.Chat(context.Background(), "永远畸形参数") + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.Metadata["terminated_by"] != TerminatedByRepairExhausted { + t.Fatalf("持续畸形参数应触发护栏提前收尾, got terminated_by=%v", resp.Metadata["terminated_by"]) + } + if resp.Metadata["partial"] != true { + t.Error("提前收尾应标记 partial") + } + // 恰在第 4 次失败后收尾:4 次工具调用轮 + 1 次 wind-down 生成。 + if len(resp.ToolCalls) != windDownThreshold { + t.Fatalf("畸形参数失败应恰计满 %d 次即收尾, got %d", windDownThreshold, len(resp.ToolCalls)) + } + if tm.calls != windDownThreshold+1 { + t.Fatalf("生成次数应为 %d(失败轮+wind-down), got %d", windDownThreshold+1, tm.calls) + } + for _, tc := range resp.ToolCalls { + if tc.Error == nil || !strings.Contains(tc.Error.Error(), "invalid arguments") { + t.Errorf("每次工具调用都应记录解析失败: %+v", tc) + } + } + if len(order) != 0 { + t.Errorf("畸形参数绝不应触达工具执行, got %v", order) + } +} diff --git a/services/cognida-go/internal/service/agent/framework/eino_react_test.go b/services/cognida-go/internal/service/agent/framework/eino_react_test.go index 90ebbcb..2fb27f1 100644 --- a/services/cognida-go/internal/service/agent/framework/eino_react_test.go +++ b/services/cognida-go/internal/service/agent/framework/eino_react_test.go @@ -19,11 +19,13 @@ type scriptedToolModel struct { script []*schema.Message idx int calls int + lastInput []*schema.Message // 最近一次 Generate 收到的输入(供断言注入的提示/观察) perCallTokens int } func (m *scriptedToolModel) Generate(ctx context.Context, input []*schema.Message, _ ...model.Option) (*schema.Message, error) { m.calls++ + m.lastInput = input var out *schema.Message if m.idx < len(m.script) { out = m.script[m.idx] diff --git a/services/cognida-go/internal/service/agent/framework/eino_tooling.go b/services/cognida-go/internal/service/agent/framework/eino_tooling.go index 7adbb79..7a50728 100644 --- a/services/cognida-go/internal/service/agent/framework/eino_tooling.go +++ b/services/cognida-go/internal/service/agent/framework/eino_tooling.go @@ -47,6 +47,10 @@ func (a *agentImpl) handleToolCall(ctx context.Context, tc schema.ToolCall, iter }) { return nil, false } + // 参数不可解析同样是「该工具的一次失败」,须计入自我修复护栏(与下方执行失败路径一致): + // 否则模型反复以畸形参数调用同一工具时,护栏对此视而不见——既不触发再规划、也不触发 + // 提前收尾,恰好绕过护栏要打破的「原地打转」。 + guard.recordFailure(tc.Function.Name, "") toolCall.Output = fmt.Sprintf("Error: 参数解析失败: %v", toolCall.Error) response.ToolCalls = append(response.ToolCalls, toolCall) return schema.ToolMessage(compactObservation(toolCall.Output), tc.ID), true diff --git a/services/cognida-go/internal/service/agent/framework/eino_toolloop.go b/services/cognida-go/internal/service/agent/framework/eino_toolloop.go index 97c498f..97883b4 100644 --- a/services/cognida-go/internal/service/agent/framework/eino_toolloop.go +++ b/services/cognida-go/internal/service/agent/framework/eino_toolloop.go @@ -31,6 +31,10 @@ const ( // 这并非有效最终答复:观察结果已在手,模型却过早停口,用户只能看到开场思考+若干工具步骤、 // 拿不到任何结论/数据(前端表现为"卡在半句话")。转 wind-down 从已有观察合成一份自洽答复。 TerminatedByEmptyFinish = "empty_finish" + // TerminatedByNoResponse 表示已跑过工具轮后,某轮生成既未返回消息也未报错/中止 + // (部分供应商/适配器在异常时会产出 (nil, nil) 而非 error)。观察结果已在手, + // 若按自然结束放行会交付空答;视作异常终止转 wind-down 从已有观察合成答复。 + TerminatedByNoResponse = "no_response" ) // maxTruncationRetries 是「finish_reason=length 截断且无工具调用」时注入精简提示后的最大重试轮数。 @@ -147,8 +151,9 @@ func (a *agentImpl) execLoop(ctx context.Context, messages []*schema.Message, ha // 迭代处理(可能需要多轮工具调用)。 // ReAct 循环受 maxIter 与 token 预算共同约束:任一到达上限即终止并收尾。 var res execResult - naturalFinish := false // 模型主动收尾(返回无工具调用的回复);区别于达上限被动终止 - truncationRetries := 0 // finish_reason=length 截断且无工具调用时的已重试次数(有界) + naturalFinish := false // 模型主动收尾(返回无工具调用的回复);区别于达上限被动终止 + hasObservation := false // 是否已产生工具观察(工具轮真正执行过);i>0 不是可靠代理——截断重试轮也消耗迭代但无观察 + truncationRetries := 0 // finish_reason=length 截断且无工具调用时的已重试次数(有界) // 挂钟护栏起点:仅在配置了 wallClock 时计时;time.Since(loopStart) 到点即终止并 wind-down。 loopStart := time.Now() // 自我修复护栏:每次运行私有,按失败签名计数触发再规划/提前收尾(并发安全)。 @@ -181,10 +186,21 @@ func (a *agentImpl) execLoop(ctx context.Context, messages []*schema.Message, ha } res.tokensUsed += usageTotalTokens(msg) - // sink 未返回消息也未报错/中止:视作自然结束,避免下面解引用 msg 崩溃。 + // sink 未返回消息也未报错/中止:避免下面解引用 msg 崩溃。 if msg == nil { + // 尚未产生任何工具观察(首轮,或仅经历过截断重试轮):无观察可合成, + // wind-down 的「从观察给出结论」无从谈起,仍按自然结束放行。 + if !hasObservation { + res.iterations = i + 1 + naturalFinish = true + break + } + // 已有工具观察在手,sink 却未返回消息:工具已执行,若按自然结束放行 + // 会交付空答(用户只看到思考+工具步骤、拿不到任何结论)。视作异常终止, + // 转 wind-down 从已有观察合成一份自洽答复,而非静默交白卷。 + log.Printf("[agent:%s] 第%d步 生成未返回消息(已跑过工具轮),转 wind-down 从观察合成收尾", a.name, i+1) + res.terminatedBy = TerminatedByNoResponse res.iterations = i + 1 - naturalFinish = true break } @@ -237,6 +253,8 @@ func (a *agentImpl) execLoop(ctx context.Context, messages []*schema.Message, ha } messages = append(messages, obs) } + // 工具轮真正执行过:此后任何异常终止(含 msg==nil)都有观察可供 wind-down 合成。 + hasObservation = true // 自我修复护栏:同一失败签名达阈值 → 注入一次再规划提示(引导换路径,非盲目重试)。 if note := guard.replanNote(); note != "" { @@ -281,11 +299,20 @@ func (a *agentImpl) execLoop(ctx context.Context, messages []*schema.Message, ha if aborted { return execResult{aborted: true}, nil } + if ferr != nil { + log.Printf("[agent:%s] wind-down 收尾生成失败: %v", a.name, ferr) + } if ferr == nil && final != nil { res.content = final.Content res.role = string(final.Role) res.tokensUsed += usageTotalTokens(final) } + // 收尾生成也未能产出内容(失败/空响应/空正文):交付诚实的降级说明而非空白回复, + // 否则用户面对静默空答无从得知运行已终止及原因。 + if strings.TrimSpace(res.content) == "" { + res.content = fmt.Sprintf("本次运行已执行 %d 轮但未能生成最终答复(终止原因:%s),建议重试或缩小问题范围。", + res.iterations, res.terminatedBy) + } res.partial = true }