diff --git a/internal/agent/core/agent_functions_test.go b/internal/agent/core/agent_functions_test.go index bfd0f38..409df9e 100644 --- a/internal/agent/core/agent_functions_test.go +++ b/internal/agent/core/agent_functions_test.go @@ -11,10 +11,10 @@ func TestEntitySimilarity(t *testing.T) { a, b string want float64 }{ - {"", "", 0}, // empty → 0 - {"a", "b", 0}, // single char → 0 - {"张三", "张三", 1.0}, // identical → 1.0 - {"张三", "李四", 0}, // no common bigrams + {"", "", 0}, // empty → 0 + {"a", "b", 0}, // single char → 0 + {"张三", "张三", 1.0}, // identical → 1.0 + {"张三", "李四", 0}, // no common bigrams {"iPhone", "iPhone 15", 0.625}, // partial overlap } for _, tt := range tests { diff --git a/internal/agent/core/plugin_health.go b/internal/agent/core/plugin_health.go index 39bbe27..e146fb0 100644 --- a/internal/agent/core/plugin_health.go +++ b/internal/agent/core/plugin_health.go @@ -7,9 +7,9 @@ import ( ) const ( - maxPluginCrashes = 3 - crashWindow = 5 * time.Minute - reloadCooldown = 30 * time.Second + maxPluginCrashes = 3 + crashWindow = 5 * time.Minute + reloadCooldown = 30 * time.Second ) type pluginHealthTracker struct { diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index f7618d5..3b4ea7a 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -218,6 +218,19 @@ func truncatedArgsError(name string, rawLen, maxTokens int) string { name, rawLen, maxTokens) } +// malformedArgsError 把「参数 JSON 写坏了」变成模型能自己改对的一句话。 +// +// 实测最常见的一种:带单位的值忘了加引号 —— `{"command": "ls", "timeout": 20s}`。 +// 工具 schema 把这类参数声明为 string、示例又写成 “10s, 1m, 30s”,模型容易照抄格式。 +// 旧实现丢整条参数,模型只看到 “command is required”,永远不知道坏在 timeout。 +func malformedArgsError(name, raw string) string { + return fmt.Sprintf( + "工具 %s 的参数不是合法 JSON,本次调用未执行(这是参数格式问题,不是工具故障)。"+ + "请重新生成完整参数并注意:所有字符串值必须带引号 —— 特别是超时/时长这种"+ + "带单位的值,要写成 \"20s\" 而不是 20s。收到 %d 字节,开头是:%s", + name, len(raw), truncateStr(raw, 160)) +} + // accumulateStream 消费 chunk channel,累积为完整 CompletionResponse, // 同时发布增量事件。返回的 response 与非流式 Chat() 的返回等价。 // @@ -248,13 +261,22 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag } else if raw == "" { log.Printf("[agent] stream tool_call %s (idx=%d) received NO argument fragments", acc.name, idx) } - // 被 MaxTokens 截断时**不要**静默降级成空参数:那会让工具报 - // "path is required" 这类与真因无关的错,模型据此重试只会再撞一次。 - // 改成把「参数不完整」原样交给模型,并附上可执行的收缩指引。 - if !argsOK && lastFinish == "length" { - log.Printf("[agent] stream tool_call %s (idx=%d) TRUNCATED by max_tokens=%d (%d bytes of args) — surfacing to model", - acc.name, idx, maxTokens, len(raw)) - args = map[string]interface{}{"__truncated_error": truncatedArgsError(acc.name, len(raw), maxTokens)} + // 参数没法解析时**绝不能**静默降级成空 map:工具只能报 “xxx is required”, + // 那与真因(参数 JSON 写坏了)毫无关系,模型据此重试只会再撞一次 + // (实测 2026-09-19:cmd_run 失败 34 次、某任务 48% 时间耗在这上面)。 + // 分两种成因给出可执行的指引: + // - finish_reason=length → 被输出上限截断,需拆小参数 + // - 其他 → JSON 写坏了(常见:带单位的值忘了引号) + if !argsOK { + if lastFinish == "length" { + log.Printf("[agent] stream tool_call %s (idx=%d) TRUNCATED by max_tokens=%d (%d bytes of args) — surfacing to model", + acc.name, idx, maxTokens, len(raw)) + args = map[string]interface{}{"__arg_error": truncatedArgsError(acc.name, len(raw), maxTokens)} + } else { + log.Printf("[agent] stream tool_call %s (idx=%d) args unparseable (%d bytes) — surfacing to model instead of calling with empty args", + acc.name, idx, len(raw)) + args = map[string]interface{}{"__arg_error": malformedArgsError(acc.name, raw)} + } } tc := agentAPI.ToolCall{ ID: acc.id, diff --git a/internal/agent/core/stages.go b/internal/agent/core/stages.go index 36cb9ee..a56c31c 100644 --- a/internal/agent/core/stages.go +++ b/internal/agent/core/stages.go @@ -166,6 +166,7 @@ func inferToolPlugin(name string) string { // 各 handler 共享 *StageContext,通过其内置 RWMutex 安全读写: // - 只读操作先调用 ctx.RLock() / defer ctx.RUnlock() // - 写操作(如设置 ctx.Response)先调用 ctx.Lock() / defer ctx.Unlock() +// // 如果任意 handler 设置了 Response,后续 handler 可通过 ctx.IsResponded() 判断后提前返回。 // handler 返回的 error 会被收集到 ctx.Errors 中并记录日志,不会中断其他 handler 的执行。 func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) { diff --git a/internal/agent/core/stream_accumulate_test.go b/internal/agent/core/stream_accumulate_test.go index c6572a1..b0682c7 100644 --- a/internal/agent/core/stream_accumulate_test.go +++ b/internal/agent/core/stream_accumulate_test.go @@ -4,6 +4,7 @@ import ( "context" "strings" "testing" + "time" agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" ) @@ -73,7 +74,7 @@ func TestAccumulateStreamContent(t *testing.T) { // 上游发 finish_reason="length"、参数 JSON 残缺。旧实现把残缺 JSON 静默降级成 // 空 map,工具只报 "path is required",模型看不出真因、原样重试四次。 // -// 本测试钉死:截断必须变成带指引的 __truncated_error,而不是空参数。 +// 本测试钉死:截断必须变成带指引的 __arg_error,而不是空参数。 func TestAccumulateStreamTruncatedArgsSurfaced(t *testing.T) { ch := make(chan agentAPI.StreamChunk, 10) go func() { @@ -95,9 +96,9 @@ func TestAccumulateStreamTruncatedArgsSurfaced(t *testing.T) { if len(resp.ToolCalls) != 1 { t.Fatalf("want 1 tool call, got %d", len(resp.ToolCalls)) } - msg, ok := resp.ToolCalls[0].Arguments["__truncated_error"].(string) + msg, ok := resp.ToolCalls[0].Arguments["__arg_error"].(string) if !ok || msg == "" { - t.Fatalf("截断的参数必须带 __truncated_error,实际 Arguments=%v", resp.ToolCalls[0].Arguments) + t.Fatalf("截断的参数必须带 __arg_error,实际 Arguments=%v", resp.ToolCalls[0].Arguments) } // 指引必须可执行:说出真因(截断/max_tokens)并给出拆小方案 for _, want := range []string{"截断", "max_tokens=4096", "拆成多次调用"} { @@ -111,9 +112,10 @@ func TestAccumulateStreamTruncatedArgsSurfaced(t *testing.T) { } } -// 非截断的残缺 JSON 保持旧行为(静默降级成空 map,由工具自己的必填校验报错): -// 这样不会把「厂商不回 finish_reason」的流也误判成截断。 -func TestAccumulateStreamInvalidArgsNotFlaggedAsTruncated(t *testing.T) { +// 非截断的残缺 JSON 也要拦住工具调用(不然工具只会报 “path is required”), +// 但**必须与真截断用不同的文案** —— 否则模型会去“拆小参数”,而它其实是写坏了。 +// 这里同时钉死两件事:①不丢给工具 ②两种成因可区分。 +func TestAccumulateStreamMalformedArgsDistinctFromTruncated(t *testing.T) { ch := make(chan agentAPI.StreamChunk, 10) go func() { ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{ @@ -130,8 +132,16 @@ func TestAccumulateStreamInvalidArgsNotFlaggedAsTruncated(t *testing.T) { if len(resp.ToolCalls) != 1 { t.Fatalf("want 1 tool call, got %d", len(resp.ToolCalls)) } - if _, ok := resp.ToolCalls[0].Arguments["__truncated_error"]; ok { - t.Error("finish_reason=tool_calls 时不应标记为截断") + msg, ok := resp.ToolCalls[0].Arguments["__arg_error"].(string) + if !ok || msg == "" { + t.Fatalf("残缺参数必须被拦住,实际 Arguments=%v", resp.ToolCalls[0].Arguments) + } + // 不能被说成“截断”:真因是 JSON 写坏,两者对模型要求的动作完全不同。 + if strings.Contains(msg, "max_tokens") || strings.Contains(msg, "截断") { + t.Errorf("非截断的残缺参数被误报为截断:%s", msg) + } + if !strings.Contains(msg, "合法 JSON") { + t.Errorf("应指出 JSON 格式问题:%s", msg) } } @@ -143,7 +153,7 @@ func TestTruncatedToolCallIsShortCircuited(t *testing.T) { ID: "call_1", Name: "files_write", Arguments: map[string]interface{}{ - "__truncated_error": truncatedArgsError("files_write", 259, 4096), + "__arg_error": truncatedArgsError("files_write", 259, 4096), }, } a := &Agent{} @@ -219,3 +229,23 @@ func TestRepairDoesNotFabricateTruncatedArgs(t *testing.T) { } } } + +// 端到端:修好 JSON 之后,字段必须**真的能被插件用上**。 +// cmd 插件走 args["timeout"].(string) 再 time.ParseDuration —— +// 若修复把 20s 变成数字或丢了引号,插件会静默忽略 timeout,等于换个姿势失败。 +func TestRepairedTimeoutUsableByPlugin(t *testing.T) { + m, ok := parseToolArgsJSON(`{"command": "ls -lt /tmp | head -20", "timeout": 20s}`) + if !ok { + t.Fatal("解析失败") + } + to, isStr := m["timeout"].(string) + if !isStr { + t.Fatalf("timeout 必须是 string,否则 cmd 插件读不到: %#v", m["timeout"]) + } + if to != "20s" { + t.Errorf("timeout 值不对: %q", to) + } + if d, err := time.ParseDuration(to); err != nil || d.Seconds() != 20 { + t.Errorf("cmd 插件下一步 ParseDuration(%q) 会失败: %v", to, err) + } +} diff --git a/internal/agent/core/stream_index_test.go b/internal/agent/core/stream_index_test.go index 1da9458..122722d 100644 --- a/internal/agent/core/stream_index_test.go +++ b/internal/agent/core/stream_index_test.go @@ -49,10 +49,10 @@ func TestAccumulateStreamParallelToolCallsByIndex(t *testing.T) { t.Fatalf("expected 4 tool calls, got %d: %+v", len(resp.ToolCalls), resp.ToolCalls) } want := map[string]string{ - "spawn_child": `{"task":"调查大模型排名"}`, + "spawn_child": `{"task":"调查大模型排名"}`, "browser_render": `{"url":"https://example.com"}`, - "cmd_run": `{"command":"uname -a"}`, - "skill_list": `{}`, + "cmd_run": `{"command":"uname -a"}`, + "skill_list": `{}`, } for _, tc := range resp.ToolCalls { raw, _ := json.Marshal(tc.Arguments) diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 10cb541..720586c 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -44,11 +44,12 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string, turnScenes } func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnScenes []string) string { - // 参数被 max_tokens 截断(见 accumulateStream):**不要**拿着残缺/空参数去调工具。 - // 否则工具会报 "path is required" 这类与真因无关的错,模型看不出是截断, - // 只会原样重试(实测连续 4 次)。直接把可执行的指引交回模型。 - if msg, ok := tc.Arguments["__truncated_error"].(string); ok && msg != "" { - log.Printf("[agent] tool %s skipped: arguments were truncated by max_tokens", tc.Name) + // 参数没法用(被 max_tokens 截断,或 JSON 写坏了):**不要**拿着空/残缺参数去调工具。 + // 否则工具会报 “path is required”“command is required” 这类与真因无关的错, + // 模型看不出真因、只能原样重试(实测 cmd_run 失败率高达 34%~48%)。 + // __arg_error 里带的已经是分因写好的可执行指引,直接交回模型。 + if msg, ok := tc.Arguments["__arg_error"].(string); ok && msg != "" { + log.Printf("[agent] tool %s skipped: arguments unusable (truncated or malformed)", tc.Name) return msg }