From fc1bacb59b56b9bc4066a83cf12abef4b7775cc0 Mon Sep 17 00:00:00 2001 From: yyz159756 Date: Wed, 5 Aug 2026 10:59:55 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=E6=A0=B9=E6=B2=BB=20?= =?UTF-8?q?Write=20=E5=8E=86=E5=8F=B2=E6=8A=98=E5=8F=A0=E8=AF=B1=E5=AF=BC?= =?UTF-8?q?=E7=9A=84=E6=A8=A1=E5=9E=8B=E8=BE=93=E5=87=BA=E9=80=80=E5=8C=96?= =?UTF-8?q?(=E6=89=A7=E8=A1=8C=E8=AE=B0=E5=BD=95=E6=9E=B6=E6=9E=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/args_repair_test.go | 9 +-- agent/history_args_test.go | 129 ++++++++++++++++++------------------- agent/llm.go | 109 +++++++++++++++++++++---------- tools/tools.go | 8 ++- tools/write_file.go | 52 ++++++++++++++- tools/write_file_test.go | 84 ++++++++++++++++++++++++ 6 files changed, 284 insertions(+), 107 deletions(-) create mode 100644 tools/write_file_test.go diff --git a/agent/args_repair_test.go b/agent/args_repair_test.go index 1234f61..8f0f6b4 100644 --- a/agent/args_repair_test.go +++ b/agent/args_repair_test.go @@ -144,8 +144,9 @@ func TestRepairThenSanitize_Compose(t *testing.T) { } } -// TestRewriteToolCallArgsForHistory_Repairs 入历史路径:空/坏 arguments 被修复, -// Write 大 content 的省略逻辑照常工作(修复在前、省略在后)。 +// TestRewriteToolCallArgsForHistory_Repairs 入历史路径:空/坏 arguments 被修复; +// 当前实现不再折叠 Write 参数 —— 大 content 原样保留(由调用方按 elidedWriteInfo +// 整体移除并渲染执行记录)。 func TestRewriteToolCallArgsForHistory_Repairs(t *testing.T) { big := strings.Repeat("x", maxInlineWriteContentBytes+1) bigArgs, _ := json.Marshal(map[string]string{"path": "a.txt", "content": big}) @@ -162,8 +163,8 @@ func TestRewriteToolCallArgsForHistory_Repairs(t *testing.T) { if got := out[1].Function.Arguments; got != `{"path":"a.go"}` { t.Fatalf("截断 arguments 应被补全, got %q", got) } - if !json.Valid([]byte(out[2].Function.Arguments)) || strings.Contains(out[2].Function.Arguments, big) { - t.Fatalf("Write 大 content 应被省略且保持合法 JSON: %q", out[2].Function.Arguments) + if !json.Valid([]byte(out[2].Function.Arguments)) || !strings.Contains(out[2].Function.Arguments, big) { + t.Fatalf("Write 大 content 应原样保留(仅修 JSON): %q", out[2].Function.Arguments) } // 执行用的原始 toolCalls 不受影响 if in[0].Function.Arguments != `` || in[1].Function.Arguments != `{"path":"a.go` { diff --git a/agent/history_args_test.go b/agent/history_args_test.go index 68c476e..d6d8d9e 100644 --- a/agent/history_args_test.go +++ b/agent/history_args_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "strings" "testing" - "unicode/utf8" ) // mkTC 构造一个工具调用。 @@ -22,86 +21,76 @@ func argsMap(t *testing.T, argsJSON string) map[string]any { return m } -func TestElideWriteContent_LargeReplacedWithReference(t *testing.T) { - // 全中文大内容:老实现按字节切会切出半个 rune,这里应整体换成引用、不含乱码。 - content := strings.Repeat("这是一段中文内容。", 200) // 远超 512 字节 - in := mkTC("Write", `{"path":"a/b/中文.go","content":`+jsonStr(content)+`}`) - - out := rewriteToolCallArgsForHistory([]ToolCall{in}) - got := out[0].Function.Arguments +// bigWriteArgs 构造一个 content 远超 512 字节的 Write 调用。 +func bigWriteArgs(path string) string { + return `{"path":"` + path + `","content":` + jsonStr(strings.Repeat("这是一段中文内容。", 200)) + `}` +} - if !utf8.ValidString(got) { - t.Fatalf("结果含非法 UTF-8(切出了半个字符): %q", got) - } - m := argsMap(t, got) - gotContent, _ := m["content"].(string) - if strings.Contains(gotContent, "这是一段中文内容") == true && len(gotContent) > 200 { - t.Fatalf("大 content 应被换成引用而非保留原文, got=%q", gotContent) +// 核心:大 content 的 Write 不再折叠参数,而是整体判定为"需外置" —— +// 由调用方从 assistant tool_calls 移除并渲染成独立执行记录。elidedWriteInfo 是判定依据。 +func TestElidedWriteInfo_LargeContent(t *testing.T) { + in := mkTC("Write", bigWriteArgs("a/b/中文.go")) + path, size, lines, ok := elidedWriteInfo(in.Function.Arguments) + if !ok { + t.Fatalf("大 content 应判定为需外置") } - if !strings.Contains(gotContent, "已写入") || !strings.Contains(gotContent, "Read") { - t.Fatalf("引用描述应含'已写入'和'Read'提示, got=%q", gotContent) + if path != "a/b/中文.go" { + t.Fatalf("path 解析错误, got=%q", path) } - if !strings.Contains(gotContent, "a/b/中文.go") { - t.Fatalf("引用描述应含文件路径, got=%q", gotContent) - } - if p, _ := m["path"].(string); p != "a/b/中文.go" { - t.Fatalf("path 应保持不变, got=%q", p) + if size <= 512 || lines < 1 { + t.Fatalf("size/lines 应反映实际内容, size=%d lines=%d", size, lines) } } -func TestElideWriteContent_SmallKeptInline(t *testing.T) { - in := mkTC("Write", `{"path":"x.txt","content":"小内容"}`) - out := rewriteToolCallArgsForHistory([]ToolCall{in}) - if out[0].Function.Arguments != in.Function.Arguments { - t.Fatalf("小 content 应原样保留\n want=%s\n got =%s", in.Function.Arguments, out[0].Function.Arguments) +func TestElidedWriteInfo_NotElided(t *testing.T) { + cases := []string{ + `{"path":"x","content":"小内容"}`, + "{broken" + strings.Repeat("x", 600), + `{"path":"x.go","command":"` + strings.Repeat("a", 600) + `"}`, + } + for _, c := range cases { + if _, _, _, ok := elidedWriteInfo(c); ok { + t.Fatalf("不应判定为需外置: %q", c) + } } } -func TestElideWriteContent_NoHTMLEscape(t *testing.T) { - // path 含 < > &,大 content 触发重编码;不应被转成 < 等。 - content := strings.Repeat("x", 600) - in := mkTC("Write", `{"path":"a&c.go","content":`+jsonStr(content)+`}`) - got := rewriteToolCallArgsForHistory([]ToolCall{in})[0].Function.Arguments - // 若 < > & 被 HTML 转义,原始 JSON 里 path 会变成 a&c.go, - // 就不再包含字面子串 "a&c.go"。含字面子串即证明未转义。 - if !strings.Contains(got, "a&c.go") { - t.Fatalf("< > & 不应被 HTML 转义(path 应保持字面量), got=%s", got) +// 执行记录:固定模板,只含确定性元信息(路径/大小/行数),不含 content 预览 +// (预览会成为新的模仿源)。模型读到的是"结果记录",语义上不会与 Write 调用范式混淆。 +func TestExecRecordMessage_FixedTemplate(t *testing.T) { + msg := execRecordMessage("config.yaml", 1247, 42) + if msg.Role != "user" { + t.Fatalf("执行记录应为 user 消息(系统注入), got=%q", msg.Role) + } + for _, want := range []string{"Write 执行记录", "工具: Write", "config.yaml", "1247", "42", "状态: 成功"} { + if !strings.Contains(msg.Content, want) { + t.Fatalf("执行记录应含 %q, got=%q", want, msg.Content) + } } - if p, _ := argsMap(t, got)["path"].(string); p != "a&c.go" { - t.Fatalf("path 应原样保留 < > &, got=%q", p) + if strings.Contains(msg.Content, "content") || strings.Contains(msg.Content, "body") { + t.Fatalf("执行记录不应含内容预览, got=%q", msg.Content) } } -func TestUpdate_NeverTruncated(t *testing.T) { - // Update 即使 old_string/new_string 巨大也一律原样保留。 - old := strings.Repeat("旧", 500) - nw := strings.Repeat("新", 500) - raw := `{"path":"f.go","old_string":` + jsonStr(old) + `,"new_string":` + jsonStr(nw) + `}` - in := mkTC("Update", raw) +// rewriteToolCallArgsForHistory 现在只修 JSON,不再折叠任何参数 —— +// 大 Write 的 content 原样保留(由调用方决定是否整体移除并渲染执行记录)。 +func TestRewrite_KeepsLargeWriteContent(t *testing.T) { + raw := bigWriteArgs("big.go") + in := mkTC("Write", raw) out := rewriteToolCallArgsForHistory([]ToolCall{in}) if out[0].Function.Arguments != raw { - t.Fatalf("Update 应原样保留,不裁剪\n want=%s\n got =%s", raw, out[0].Function.Arguments) + t.Fatalf("不折叠参数:大 Write content 应原样保留\n want=%s\n got =%s", raw, out[0].Function.Arguments) } } -func TestOtherTools_Untouched(t *testing.T) { - in := mkTC("Bash", `{"command":"`+strings.Repeat("echo ", 300)+`"}`) +func TestRewrite_RepairsBadJSON(t *testing.T) { + // 坏 arguments 仍应被修复为合法 JSON(issue #201 防严格后端 400)。 + in := mkTC("Bash", `{"command":`) out := rewriteToolCallArgsForHistory([]ToolCall{in}) - if out[0].Function.Arguments != in.Function.Arguments { - t.Fatalf("非 Write 工具不应被改动") - } -} - -func TestInvalidJSON_ReturnedAsIs(t *testing.T) { - // 超过阈值但不是合法 JSON:原样返回,不 panic。 - broken := "{not json" + strings.Repeat("x", 600) - if got := elideWriteContent(broken); got != broken { - t.Fatalf("非法 JSON 应原样返回") - } + argsMap(t, out[0].Function.Arguments) // 合法 JSON 断言 } func TestRewrite_DoesNotMutateOriginal(t *testing.T) { - // 执行仍用原始 toolCalls:确认原始未被改动。 content := strings.Repeat("y", 600) raw := `{"path":"z.go","content":` + jsonStr(content) + `}` orig := []ToolCall{mkTC("Write", raw)} @@ -119,14 +108,22 @@ func TestRewrite_MixedBatch(t *testing.T) { mkTC("Read", `{"path":"r.go"}`), } out := rewriteToolCallArgsForHistory(tcs) - if c, _ := argsMap(t, out[0].Function.Arguments)["content"].(string); !strings.Contains(c, "已写入") { - t.Fatalf("批次中的大 Write 应被换引用, got=%q", c) + for i := range out { + if out[i].Function.Arguments != tcs[i].Function.Arguments { + t.Fatalf("不折叠任何参数(仅修 JSON), 第 %d 个被改动:\n want=%s\n got =%s", i, tcs[i].Function.Arguments, out[i].Function.Arguments) + } } - if out[1].Function.Arguments != tcs[1].Function.Arguments { - t.Fatalf("批次中的 Update 应原样") - } - if out[2].Function.Arguments != tcs[2].Function.Arguments { - t.Fatalf("批次中的 Read 应原样") +} + +// 多轮连续 Write 大文件:每轮的 Write 都应被 elidedWriteInfo 识别为"需外置", +// 调用方据此把它从 assistant tool_calls 移除 → 历史里不存在任何 +// "缺 content / 带折叠标记"的伪 Write,模型学到的 Write 范式始终完整。 +func TestMultiTurn_AllLargeWritesElided(t *testing.T) { + for i := 0; i < 5; i++ { + in := mkTC("Write", `{"path":"f`+string(rune('a'+i))+`.txt","content":`+jsonStr(strings.Repeat("内容", 300))+`}`) + if _, _, _, ok := elidedWriteInfo(in.Function.Arguments); !ok { + t.Fatalf("第 %d 轮大 Write 应判定为需外置", i+1) + } } } diff --git a/agent/llm.go b/agent/llm.go index a3a9362..e100146 100644 --- a/agent/llm.go +++ b/agent/llm.go @@ -836,13 +836,27 @@ func StartStream( } // 把本轮 assistant 回复写入历史(含 reasoning_content,thinking 模型下轮需要) - // Write 的大 content 换成文件引用描述(文件已实际写入,历史留引用即可、需要时 Read), - // Update 原样保留(其 diff 语义 Read 补不回来)。详见 rewriteToolCallArgsForHistory。 + // 大 content 的 Write 调用不进入 assistant tool_calls —— 渲染成独立的"执行记录" + // 消息(见下方执行循环),历史里只保留完整结构的 {path, content} 调用范式, + // 模型不会学到"缺 content / 带折叠标记"的伪 Write 形态。Update 原样保留 + // (diff 语义 Read 补不回来)。 + histToolCalls := rewriteToolCallArgsForHistory(toolCalls) + elidedIDs := make(map[string]bool) // 大 content Write(需外置)的 tool_call ID → 渲染执行记录 + kept := histToolCalls[:0:0] + for _, tc := range histToolCalls { + if tc.Function.Name == "Write" { + if _, _, _, ok := elidedWriteInfo(tc.Function.Arguments); ok { + elidedIDs[tc.ID] = true + continue // 从 assistant tool_calls 移除,不呈现伪调用 + } + } + kept = append(kept, tc) + } convo = append(convo, ChatMessage{ Role: "assistant", Content: assistantContent, ReasoningContent: reasoning, - ToolCalls: rewriteToolCallArgsForHistory(toolCalls), + ToolCalls: kept, }) if len(toolCalls) == 0 { @@ -1114,6 +1128,25 @@ func StartStream( Output: result.Output, Success: result.Success, } + // 大 content Write 渲染为独立的"执行记录"消息(固定模板), + // 替代 tool 结果消息 —— 模型读到的是确定性的结果记录(路径/大小/行数), + // 不是被改写的伪 tool call,不会把 {path} / content_omitted 等形态学成 + // Write 的标准写法。仅写入成功时渲染执行记录;失败时走普通 tool 消息, + // 让错误信息原样透传给模型(否则"状态: 成功"会掩盖失败,误导模型)。 + if elidedIDs[tc.ID] { + if result.Success { + path, size, lines, _ := elidedWriteInfo(tc.Function.Arguments) + convo = append(convo, execRecordMessage(path, size, lines)) + } else { + convo = append(convo, ChatMessage{ + Role: "tool", + ToolCallID: tc.ID, + Name: tc.Function.Name, + Content: clampTurnToolOutput(tc.Function.Name, result.Output, &turnToolBytes), + }) + } + continue + } convo = append(convo, ChatMessage{ Role: "tool", ToolCallID: tc.ID, @@ -1777,59 +1810,65 @@ func runExecutorGuarded(t *tools.Tool, args map[string]any) tools.ToolResult { } } -// maxInlineWriteContentBytes 是 Write 的 content 存入历史时保留原文的字节上限。 -// 超过则把 content 整体替换为文件引用描述(文件已实际写入,历史留引用即可、需要时 Read 取回), -// 而不是截断出一段片段 —— 既避免完整文件内容撑爆上下文,也免去"按字节切多字节字符切出半个字"的麻烦。 +// maxInlineWriteContentBytes 是 Write 的 content 不进入普通上下文的最大字节数。 +// 超过则把该 Write 调用从 assistant tool_calls 中移除、渲染成独立的"执行记录"消息 +// (见 execRecordMessage),content 本身不进入上下文 —— 既防膨胀,也避免"参数被改写" +// 的形态污染模型(历史里永远不会出现缺 content / 带折叠标记的伪 tool call)。 // 未超则原样保留:小内容占不了多少上下文,留着还能让模型看到自己刚写了什么。 +// 折叠判定与执行记录信息由 elidedWriteInfo 提供,模型读到的是"已写入 N bytes / M 行" +// 的确定性元信息,无需 Read 验证。 const maxInlineWriteContentBytes = 512 // rewriteToolCallArgsForHistory 生成"存入历史用"的 toolCalls 副本: -// 把 Write 的大 content 替换成文件引用描述,避免完整文件内容撑爆上下文。 -// Update 一律原样保留 —— 其 old_string/new_string 承载"把什么改成了什么"的 diff 语义, -// 这信息不在文件里、Read 也补不回来,故不裁剪。 -// 只影响存入历史的版本,执行仍用原始 toolCalls,二者互不影响(ToolCall/ToolCallFunc 皆值类型)。 +// 只做 arguments 的 JSON 修复(空→{}、截断补全、垃圾兜底,见 args_repair.go); +// 不再对 Write 折叠参数 —— 大 content 的 Write 调用会整体从 assistant +// tool_calls 中移除,改由独立的"执行记录"消息呈现(见 streamAttempt 的组装逻辑), +// 因此历史里**不会出现**缺 content / 带折叠标记的伪 tool call,模型学到的 Write +// 范式始终是完整的 {path, content}。 +// Update 一律原样保留 —— 其 old_string/new_string 承载"把什么改成了什么"的 diff 语义。 func rewriteToolCallArgsForHistory(tcs []ToolCall) []ToolCall { out := make([]ToolCall, len(tcs)) for i, tc := range tcs { out[i] = tc - // 入历史前把 arguments 修成合法 JSON(空→{}、截断补全、垃圾兜底包裹,见 args_repair.go): - // 历史里的坏 arguments 会让 vLLM 等严格后端对后续所有请求 400,会话不可恢复(issue #201)。 - // 执行仍用原始 toolCalls,不受影响。 out[i].Function.Arguments = repairArgsJSON(out[i].Function.Arguments) - if tc.Function.Name == "Write" { - out[i].Function.Arguments = elideWriteContent(out[i].Function.Arguments) - } } return out } -// elideWriteContent 若 Write 的 content 超过 maxInlineWriteContentBytes, -// 把它替换为 "[已写入 ,N 字节/M 行;需要内容用 Read 查看]" 的引用描述并重新序列化; -// content 够短、解析失败、或字段缺失都原样返回。 -// 重新编码用 json.Encoder + SetEscapeHTML(false),避免 path 里的 < > & 被转义成 < 等。 -func elideWriteContent(argsJSON string) string { +// elidedWriteInfo 判断 Write 是否"大 content 需外置":content 超过 maxInlineWriteContentBytes 时 +// 返回 (path, size, lines, true),供调用方把该 Write 从 assistant tool_calls 中移除、 +// 并渲染成独立的执行记录消息(固定模板,prefix cache 友好); +// 未超 / 解析失败 / 字段缺失返回 ok=false(按普通 tool_call 入历史)。 +// +// 设计原则:content 不进普通上下文,由执行记录提供确定性元信息(大小/行数),模型无需 Read 验证; +// 且历史里不再出现"缺 content / 带折叠标记"的伪 tool call —— 那是历史版本结构污染的根源 +// (模型会把任何"系统改写的参数形态"当成 Write 的标准写法模仿)。 +func elidedWriteInfo(argsJSON string) (path string, size, lines int, ok bool) { if len(argsJSON) <= maxInlineWriteContentBytes { - return argsJSON // 整个 arguments 都没超,content 必然没超,免解析 + return "", 0, 0, false } var args map[string]any if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return argsJSON + return "", 0, 0, false } - content, ok := args["content"].(string) - if !ok || len(content) <= maxInlineWriteContentBytes { - return argsJSON + content, okc := args["content"].(string) + if !okc || len(content) <= maxInlineWriteContentBytes { + return "", 0, 0, false } - path, _ := args["path"].(string) - lines := strings.Count(content, "\n") + 1 - args["content"] = fmt.Sprintf("[已写入 %s,%d 字节/%d 行;需要内容用 Read 查看]", path, len(content), lines) + path, _ = args["path"].(string) + return path, len(content), strings.Count(content, "\n") + 1, true +} - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetEscapeHTML(false) - if err := enc.Encode(args); err != nil { - return argsJSON +// execRecordMessage 生成大 Write 的"执行记录"消息(role=user,系统注入,固定模板)。 +// 固定文本只有 工具/状态 不变,变化的是 路径/大小/行数 —— prefix cache 友好; +// 不给内容预览(预览会成为新的模仿源)。模型读到的是"结果记录"而非 tool call 参数, +// 语义上不会与 Write 的调用范式混淆。 +func execRecordMessage(path string, size, lines int) ChatMessage { + return ChatMessage{ + Role: "user", + Content: fmt.Sprintf("[Write 执行记录]\n工具: Write\n路径: %s\n状态: 成功\n大小: %d 字节\n行数: %d", + path, size, lines), } - return strings.TrimRight(buf.String(), "\n") // Encode 会补一个换行,去掉 } // --- 工具输出回收(reclaim,issue #201)--- diff --git a/tools/tools.go b/tools/tools.go index 33ed80e..619d1da 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -189,7 +189,13 @@ var Tools = []Tool{ Description: "写入(覆盖)文本文件。父目录会自动创建。\n\n" + "⚠️ 超大文件分块写:content 会整体计入本次输出,一次写太多可能撞上单次输出上限被截断、" + "导致本次调用失败——且对话越长、可用输出预算越小,越容易触发。稳妥做法:先用 Write 写入" + - "开头一部分,再用 Update 逐段追加余下内容,把每次写入拆小。", + "开头一部分,再用 Update 逐段追加余下内容,把每次写入拆小。\n\n" + + "content 必须是你要写入的完整真实文件内容,禁止用元描述/占位标记代替内容。\n" + + "正确写法:content 包含文件的实际文本(代码、配置、文档的完整内容)。\n" + + "错误写法:content 只是一句描述性文字(如'内容已省略'或文件信息摘要)——那会被原样写进文件。\n" + + "若不确定文件当前内容,先 Read 再写;创建空文件请用 python -c \"open('路径','w').close()\"。\n" + + "注意:历史中,大文件的 Write 调用以执行记录形式呈现(路径/大小/行数),完整内容" + + "已写入文件,需要核对时 Read 该文件即可。Write 成功后无需立即 Read 验证。", Parameters: ToolParam{ Type: "object", Properties: map[string]PropDef{ diff --git a/tools/write_file.go b/tools/write_file.go index 38d4f97..b779753 100644 --- a/tools/write_file.go +++ b/tools/write_file.go @@ -1,22 +1,70 @@ package tools import ( + "crypto/sha256" + "encoding/hex" "fmt" "os" "path/filepath" + "strings" ) +// minPlaceholderCheckBytes:content 短于该字节数时才做占位符模式检测。 +// 正常的小文件(如几字节配置)很少短于 32 字节,误判率低;长内容即使含个别 +// 占位符字样也可能是真实文本,不拦(避免误伤)。 +const minPlaceholderCheckBytes = 32 + +// placeholderPatterns:疑似「缺失值/占位标记」的文本模式(小写匹配)。 +// 命中说明模型很可能把元描述/占位符当 content 写入了(上下文污染),应拒绝并提示重写。 +// 注意:错误提示里刻意不回显这些具体字符串 —— 错误信息会进模型上下文, +// 回显占位符等于再把污染样本喂回去(对话消毒原则)。 +var placeholderPatterns = []string{ + "", "", "", "", "", "", + "[已写入", "[write参数", "[参数已折叠", "[content ", "content omitted", + "file content omitted", "[truncated]", "... (truncated)", "内容已省略", "内容省略", +} + +// validateWriteContent 写入前校验 content 是否为疑似占位符/缺失值文本: +// - 空内容 → 拒绝(写空文件请用其他方式,如 python 建空文件); +// - 内容过短且命中占位符模式 → 拒绝。 +// +// 错误提示只描述性质、给修正方向,不回显占位符文本。 +func validateWriteContent(content string) error { + if strings.TrimSpace(content) == "" { + return fmt.Errorf("Write 拒绝: content 为空。Write 的 content 必须是完整文件内容;" + + "若要创建空文件,请用 python -c \"open('目标路径','w').close()\"(Windows 无 touch)," + + "或提供真实内容") + } + if len(content) < minPlaceholderCheckBytes { + low := strings.ToLower(content) + for _, p := range placeholderPatterns { + if strings.Contains(low, p) { + return fmt.Errorf("Write 拒绝: content 疑似缺失值/占位标记,而非真实文件内容。" + + "这通常是上下文污染导致模型把元描述当内容输出。请重新提供完整的真实内容;" + + "若确需写入极短内容,请补充上下文或调整写法") + } + } + } + return nil +} + // WriteFile 写入(覆盖)文本文件。 // 参数: // // path (string) 文件路径 // content (string) 写入的内容 +// +// 成功结果给出确定性元信息(字节数/行数/sha256 前缀),让模型"确实写入了"、 +// 无需立即 Read 验证;不放内容预览(预览会成为新的模仿源)。 func WriteFile(args map[string]any) ToolResult { path, _ := args["path"].(string) if path == "" { return ToolResult{Output: "错误: path 参数为空", Success: false} } content, _ := args["content"].(string) + if err := validateWriteContent(content); err != nil { + return ToolResult{Output: err.Error(), Success: false} + } absPath, err := confineToWorkspace(path) if err != nil { @@ -29,8 +77,10 @@ func WriteFile(args map[string]any) ToolResult { return ToolResult{Output: fmt.Sprintf("写入失败: %v", err), Success: false} } CodeGraphInvalidate() // 文件变了,代码图谱缓存失效,下次查询重建 + sum := sha256.Sum256([]byte(content)) + lines := strings.Count(content, "\n") + 1 return ToolResult{ - Output: fmt.Sprintf("已写入 %s (%d bytes)", absPath, len(content)), + Output: fmt.Sprintf("已写入 %s (%d bytes, %d 行, sha256: %s)", absPath, len(content), lines, hex.EncodeToString(sum[:4])), Success: true, } } diff --git a/tools/write_file_test.go b/tools/write_file_test.go new file mode 100644 index 0000000..bad919e --- /dev/null +++ b/tools/write_file_test.go @@ -0,0 +1,84 @@ +package tools + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateWriteContent_Valid(t *testing.T) { + cases := []string{ + strings.Repeat("hello world\n", 10), // 正常长内容 + "hello", // 极短但正常,不命中占位符 + strings.Repeat("a", 100) + "marker" + strings.Repeat("b", 100), // 长内容即使含个别字样也放行 + } + for _, c := range cases { + if err := validateWriteContent(c); err != nil { + t.Fatalf("应放行: %q, err=%v", c, err) + } + } +} + +func TestValidateWriteContent_Rejected(t *testing.T) { + cases := []string{ + "", + " \n ", + "", + "", + "", + "", + "[已写入 a.txt", + "[参数已折叠", + "content omitted", + "内容已省略", + } + for _, c := range cases { + if err := validateWriteContent(c); err == nil { + t.Fatalf("应拒绝: %q", c) + } + } +} + +func TestWriteFile_RejectsPlaceholder(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "x.txt") + res := WriteFile(map[string]any{"path": p, "content": ""}) + if res.Success { + t.Fatalf("占位符写入应被拒绝, got=%+v", res) + } + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Fatalf("拒绝后不应产生文件, err=%v", err) + } +} + +func TestValidateWriteContent_EmptyMessageMentionsPython(t *testing.T) { + // 空内容提示应引导 python 而非 touch(Windows 无 touch)。 + err := validateWriteContent("") + if err == nil { + t.Fatalf("空内容应被拒绝") + } + if !strings.Contains(err.Error(), "python") { + t.Fatalf("空内容提示应给出 python 建空文件方法, got=%q", err.Error()) + } +} + +func TestWriteFile_AllowsRealContent(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "real.txt") + content := "line1\nline2\n" + strings.Repeat("fill\n", 30) + res := WriteFile(map[string]any{"path": p, "content": content}) + if !res.Success { + t.Fatalf("真实内容应写入成功, got=%+v", res) + } + b, err := os.ReadFile(p) + if err != nil || string(b) != content { + t.Fatalf("写入内容不一致, err=%v", err) + } + // 成功结果应给出确定性元信息(字节数/行数/校验值),让模型无需 Read 验证。 + for _, want := range []string{"bytes", "行", "sha256"} { + if !strings.Contains(res.Output, want) { + t.Fatalf("成功结果应含 %q(确定性元信息), got=%q", want, res.Output) + } + } +} From 3eb3b78e1f553c54add5959818869f032dd2bbe5 Mon Sep 17 00:00:00 2001 From: yyz159756 Date: Wed, 5 Aug 2026 16:20:22 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=E6=8A=98=E5=8F=A0=20?= =?UTF-8?q?Write=20=E6=89=A7=E8=A1=8C=E8=AE=B0=E5=BD=95=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=BE=AA=E7=8E=AF=E6=9C=AB=E5=B0=BE=E7=BB=9F=E4=B8=80=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0,=E4=BF=AE=E5=A4=8D=E5=8D=8F=E8=AE=AE=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/llm.go | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/agent/llm.go b/agent/llm.go index e100146..79a82c4 100644 --- a/agent/llm.go +++ b/agent/llm.go @@ -888,6 +888,11 @@ func StartStream( // pendingImageInjects:本轮被 redirect 的 OCR(视觉模型 + 真实外部图)对应的图片路径, // 在 tc 循环收尾处统一追加成带图 user 消息,让模型下一轮直接看图(见 case "OCR",issue #194)。 var pendingImageInjects []string + // pendingExecRecords:本轮被外置(折叠)的大 Write 执行记录消息,统一在循环末尾 + // (所有 tool 消息之后)追加 —— 不能即时插在 assistant 与 tool 消息之间, + // 否则违反 OpenAI 协议(assistant 带 tool_calls 后必须紧跟 tool 响应,中间不能插 + // user 消息,严格后端会 400:insufficient tool messages following tool_calls)。 + var pendingExecRecords []ChatMessage for _, tc := range toolCalls { // review 模式:对 Write/Update/Bash 发起审核。 // Workflow(run) 无论何种模式都强制确认:它会执行模型生成的脚本(进而派子 agent)。 @@ -1131,18 +1136,20 @@ func StartStream( // 大 content Write 渲染为独立的"执行记录"消息(固定模板), // 替代 tool 结果消息 —— 模型读到的是确定性的结果记录(路径/大小/行数), // 不是被改写的伪 tool call,不会把 {path} / content_omitted 等形态学成 - // Write 的标准写法。仅写入成功时渲染执行记录;失败时走普通 tool 消息, - // 让错误信息原样透传给模型(否则"状态: 成功"会掩盖失败,误导模型)。 + // Write 的标准写法。成功/失败都进 pendingExecRecords(循环末尾统一追加, + // 见上方注释):不能即时插在 assistant 与 tool 之间(OpenAI 协议要求 + // assistant 带 tool_calls 后必须紧跟 tool 响应,严格后端会 400), + // 也不能用带 tool_call_id 的 tool 消息(该 ID 已从 assistant tool_calls + // 移除,会产生孤儿 tool 消息 → 同样 400)。 if elidedIDs[tc.ID] { if result.Success { path, size, lines, _ := elidedWriteInfo(tc.Function.Arguments) - convo = append(convo, execRecordMessage(path, size, lines)) + pendingExecRecords = append(pendingExecRecords, execRecordMessage(path, size, lines)) } else { - convo = append(convo, ChatMessage{ - Role: "tool", - ToolCallID: tc.ID, - Name: tc.Function.Name, - Content: clampTurnToolOutput(tc.Function.Name, result.Output, &turnToolBytes), + pendingExecRecords = append(pendingExecRecords, ChatMessage{ + Role: "user", + Content: fmt.Sprintf("[Write 执行记录]\n工具: %s\n路径: %s\n状态: 失败\n说明: %s", + tc.Function.Name, toolArgPath(tc.Function.Arguments), result.Output), }) } continue @@ -1156,6 +1163,8 @@ func StartStream( Content: clampTurnToolOutput(tc.Function.Name, result.Output, &turnToolBytes), }) } + // 折叠 Write 的执行记录统一在所有 tool 消息之后追加(协议安全,见 pendingExecRecords 注释)。 + convo = append(convo, pendingExecRecords...) // 视觉模型下被 redirect 的 OCR:把对应图片作为独立 user 消息追加进对话(带 ImagePaths, // renderConvoImages 下一轮按当轮模型能力渲染成 base64 / 路径+OCR,切模型也安全)。 // 追加在所有 tool 结果之后,让模型下一轮直接看到内联的图(issue #194)。 @@ -1871,6 +1880,16 @@ func execRecordMessage(path string, size, lines int) ChatMessage { } } +// toolArgPath 从工具调用参数中提取 path(供执行记录失败分支显示路径)。 +func toolArgPath(argsJSON string) string { + var args map[string]any + if json.Unmarshal([]byte(argsJSON), &args) != nil { + return "" + } + p, _ := args["path"].(string) + return p +} + // --- 工具输出回收(reclaim,issue #201)--- // // 问题:deepx 的历史压缩按 user 轮切,保护最近 keepRecentTurns 个 user 轮。但"跑测试 / 长任务" From 906ae99f780e9ecc69298273c52ff01a6c5e0de8 Mon Sep 17 00:00:00 2001 From: yyz159756 Date: Wed, 5 Aug 2026 16:40:56 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E8=AE=B0=E5=BD=95/=E5=A4=B1=E8=B4=A5=E9=80=8F=E4=BC=A0/?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E7=BC=93=E5=AD=98=E4=B8=89=E4=B8=AA=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/llm.go | 96 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 60 insertions(+), 36 deletions(-) diff --git a/agent/llm.go b/agent/llm.go index 79a82c4..8b9efa7 100644 --- a/agent/llm.go +++ b/agent/llm.go @@ -836,27 +836,28 @@ func StartStream( } // 把本轮 assistant 回复写入历史(含 reasoning_content,thinking 模型下轮需要) - // 大 content 的 Write 调用不进入 assistant tool_calls —— 渲染成独立的"执行记录" - // 消息(见下方执行循环),历史里只保留完整结构的 {path, content} 调用范式, - // 模型不会学到"缺 content / 带折叠标记"的伪 Write 形态。Update 原样保留 - // (diff 语义 Read 补不回来)。 + // 大 content 的 Write:assistant 先带完整 tool_calls 入 convo(参数经 repairArgsJSON, + // 保证配对完整、失败时错误能透传);工具循环后只把【成功】的大 Write 从中摘除并渲染 + // 执行记录 —— 失败的大 Write 保留 tool_call,其 tool 错误消息正常配对。 + // 这样:① 失败错误不再被吞(模型看得到"状态: 失败");② 成功的大 Write 最终历史 + // 仍是"执行记录 + 无伪 tool call",防膨胀与防污染不变;③ 不产生悬挂 tool_call。 histToolCalls := rewriteToolCallArgsForHistory(toolCalls) - elidedIDs := make(map[string]bool) // 大 content Write(需外置)的 tool_call ID → 渲染执行记录 - kept := histToolCalls[:0:0] + // elided:大 content Write 的判定结果缓存(问题 3:只算一次,避免组装用修复后 args、 + // 执行用原始 args 两次判定不一致 → 空路径/0 字节记录)。 + elided := make(map[string]elidedWrite) for _, tc := range histToolCalls { if tc.Function.Name == "Write" { - if _, _, _, ok := elidedWriteInfo(tc.Function.Arguments); ok { - elidedIDs[tc.ID] = true - continue // 从 assistant tool_calls 移除,不呈现伪调用 + if p, s, l, ok := elidedWriteInfo(tc.Function.Arguments); ok { + elided[tc.ID] = elidedWrite{path: p, size: s, lines: l} } } - kept = append(kept, tc) } + assistantIdx := len(convo) convo = append(convo, ChatMessage{ Role: "assistant", Content: assistantContent, ReasoningContent: reasoning, - ToolCalls: kept, + ToolCalls: histToolCalls, // 先带全部(含大 Write),循环后摘除成功项 }) if len(toolCalls) == 0 { @@ -893,6 +894,10 @@ func StartStream( // 否则违反 OpenAI 协议(assistant 带 tool_calls 后必须紧跟 tool 响应,中间不能插 // user 消息,严格后端会 400:insufficient tool messages following tool_calls)。 var pendingExecRecords []ChatMessage + // successElidedIDs:本轮执行【成功】的大 Write tool_call ID —— 循环后从 assistant + // tool_calls 摘除(它们没有对应 tool 消息,不摘会留下悬挂 tool_call); + // 失败的大 Write 不在其中(保留 tool_call,错误消息正常配对)。 + var successElidedIDs []string for _, tc := range toolCalls { // review 模式:对 Write/Update/Bash 发起审核。 // Workflow(run) 无论何种模式都强制确认:它会执行模型生成的脚本(进而派子 agent)。 @@ -1133,23 +1138,21 @@ func StartStream( Output: result.Output, Success: result.Success, } - // 大 content Write 渲染为独立的"执行记录"消息(固定模板), - // 替代 tool 结果消息 —— 模型读到的是确定性的结果记录(路径/大小/行数), - // 不是被改写的伪 tool call,不会把 {path} / content_omitted 等形态学成 - // Write 的标准写法。成功/失败都进 pendingExecRecords(循环末尾统一追加, - // 见上方注释):不能即时插在 assistant 与 tool 之间(OpenAI 协议要求 - // assistant 带 tool_calls 后必须紧跟 tool 响应,严格后端会 400), - // 也不能用带 tool_call_id 的 tool 消息(该 ID 已从 assistant tool_calls - // 移除,会产生孤儿 tool 消息 → 同样 400)。 - if elidedIDs[tc.ID] { + // 大 content Write: + // - 成功:渲染执行记录(用缓存的 elided 判定结果),进 pendingExecRecords, + // 该 tool_call 在循环后从 assistant 摘除(无对应 tool 消息,避免悬挂); + // - 失败:保留 tool_call(assistant 已带),append tool 消息正常配对, + // 错误信息原样透传给模型 —— 不再被吞、不再伪装"状态: 成功"。 + if ew, isElided := elided[tc.ID]; isElided { if result.Success { - path, size, lines, _ := elidedWriteInfo(tc.Function.Arguments) - pendingExecRecords = append(pendingExecRecords, execRecordMessage(path, size, lines)) + pendingExecRecords = append(pendingExecRecords, execRecordMessage(ew.path, ew.size, ew.lines)) + successElidedIDs = append(successElidedIDs, tc.ID) } else { - pendingExecRecords = append(pendingExecRecords, ChatMessage{ - Role: "user", - Content: fmt.Sprintf("[Write 执行记录]\n工具: %s\n路径: %s\n状态: 失败\n说明: %s", - tc.Function.Name, toolArgPath(tc.Function.Arguments), result.Output), + convo = append(convo, ChatMessage{ + Role: "tool", + ToolCallID: tc.ID, + Name: tc.Function.Name, + Content: clampTurnToolOutput(tc.Function.Name, result.Output, &turnToolBytes), }) } continue @@ -1163,6 +1166,17 @@ func StartStream( Content: clampTurnToolOutput(tc.Function.Name, result.Output, &turnToolBytes), }) } + // 摘除成功的大 Write tool_call(它们无对应 tool 消息,保留会悬挂;失败项保留配对)。 + if len(successElidedIDs) > 0 { + kept := convo[assistantIdx].ToolCalls[:0:0] + for _, tc := range convo[assistantIdx].ToolCalls { + if containsID(successElidedIDs, tc.ID) { + continue + } + kept = append(kept, tc) + } + convo[assistantIdx].ToolCalls = kept + } // 折叠 Write 的执行记录统一在所有 tool 消息之后追加(协议安全,见 pendingExecRecords 注释)。 convo = append(convo, pendingExecRecords...) // 视觉模型下被 redirect 的 OCR:把对应图片作为独立 user 消息追加进对话(带 ImagePaths, @@ -1852,6 +1866,9 @@ func rewriteToolCallArgsForHistory(tcs []ToolCall) []ToolCall { // 设计原则:content 不进普通上下文,由执行记录提供确定性元信息(大小/行数),模型无需 Read 验证; // 且历史里不再出现"缺 content / 带折叠标记"的伪 tool call —— 那是历史版本结构污染的根源 // (模型会把任何"系统改写的参数形态"当成 Write 的标准写法模仿)。 +// +// 注意:调用方应把结果缓存(见 elided map),避免组装(修复后 args)与执行循环(原始 args) +// 各算一次导致判定不一致 → 空路径 / 0 字节的"成功"记录。 func elidedWriteInfo(argsJSON string) (path string, size, lines int, ok bool) { if len(argsJSON) <= maxInlineWriteContentBytes { return "", 0, 0, false @@ -1868,6 +1885,23 @@ func elidedWriteInfo(argsJSON string) (path string, size, lines int, ok bool) { return path, len(content), strings.Count(content, "\n") + 1, true } +// elidedWrite 是 elidedWriteInfo 的缓存结果,供组装/执行两处共用(只算一次)。 +type elidedWrite struct { + path string + size int + lines int +} + +// containsID 判断 ID 是否在集合中。 +func containsID(ids []string, id string) bool { + for _, x := range ids { + if x == id { + return true + } + } + return false +} + // execRecordMessage 生成大 Write 的"执行记录"消息(role=user,系统注入,固定模板)。 // 固定文本只有 工具/状态 不变,变化的是 路径/大小/行数 —— prefix cache 友好; // 不给内容预览(预览会成为新的模仿源)。模型读到的是"结果记录"而非 tool call 参数, @@ -1880,16 +1914,6 @@ func execRecordMessage(path string, size, lines int) ChatMessage { } } -// toolArgPath 从工具调用参数中提取 path(供执行记录失败分支显示路径)。 -func toolArgPath(argsJSON string) string { - var args map[string]any - if json.Unmarshal([]byte(argsJSON), &args) != nil { - return "" - } - p, _ := args["path"].(string) - return p -} - // --- 工具输出回收(reclaim,issue #201)--- // // 问题:deepx 的历史压缩按 user 轮切,保护最近 keepRecentTurns 个 user 轮。但"跑测试 / 长任务" From 228ffae53f89bcb5d65b3f8ab5a202cc7ef1c382 Mon Sep 17 00:00:00 2001 From: yyz159756 Date: Wed, 5 Aug 2026 17:05:06 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E8=AE=B0=E5=BD=95(role=3Duser)=E4=B8=8D=E6=B1=A1=E6=9F=93?= =?UTF-8?q?=E6=B8=B2=E6=9F=93/=E8=BD=AE=E6=95=B0=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=8A=A0=20IsExecRecord=20=E6=A0=87=E8=AE=B0=E5=B9=B6=E8=BF=87?= =?UTF-8?q?=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/compact.go | 10 +++++++++- agent/llm.go | 11 ++++++++--- tui/model.go | 4 ++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/agent/compact.go b/agent/compact.go index 5f9c3fc..b5b5bbf 100644 --- a/agent/compact.go +++ b/agent/compact.go @@ -77,7 +77,15 @@ func CutHistory(history []ChatMessage, cutIdx int) []ChatMessage { // 只认 user 就找不到切点,压缩要么退到最前面(等于不压)、要么把整个长轮全保住; // - tool 消息必须紧跟发起调用的 assistant,切在它上面会留下孤儿 tool → API 400 // (见 sanitizeToolPairs);而切在 assistant 上,它的 tool 结果自然跟着一起保留,配对不坏。 -func isTurnBoundary(m ChatMessage) bool { return m.Role == "user" || m.Role == "assistant" } +// isTurnBoundary 判断消息是否为"对话轮边界"(user/assistant)。 +// 系统注入的执行记录(IsExecRecord)虽然 role=user,但不是真实对话轮 —— 不计为边界, +// 否则每个大 Write 都虚增一轮、干扰压缩的轮数判断与切点。 +func isTurnBoundary(m ChatMessage) bool { + if m.IsExecRecord { + return false + } + return m.Role == "user" || m.Role == "assistant" +} // compactionTimeout 是摘要 LLM 调用的硬超时。没有它,卡住的请求会让压缩锁永远占住、把所有压缩堵死。 // 给得宽松(容纳大摘要生成 + 本地慢模型,如 4090D 上跑 qwen 摘要大历史,见 issue #201), diff --git a/agent/llm.go b/agent/llm.go index 8b9efa7..3455156 100644 --- a/agent/llm.go +++ b/agent/llm.go @@ -209,6 +209,11 @@ type ChatMessage struct { // 切换当前模式不会改写历史消息的后缀 → 历史逐字节稳定、前缀缓存不 miss。空值兜底为默认 kp。 // 同 ImagePaths 走"规范形态只存标签、发送那刻才渲染"的思路。gob 持久化(导出字段)。 WorkingMode WorkingMode `json:"-"` + // IsExecRecord 标记系统注入的"工具执行记录"消息(role=user 但非用户所说): + // 渲染(rebuildChatFromHistory)/轮数统计(isTurnBoundary)应跳过,避免: + // ① 用户气泡里冒出从没说过的话;② 每个大 Write 虚增对话轮、干扰压缩切点。 + // 仅运行时字段,不进 JSON 序列化。 + IsExecRecord bool `json:"-"` } // ContentPart 是 OpenAI 多模态消息里 content 数组的一个元素。 @@ -1908,9 +1913,9 @@ func containsID(ids []string, id string) bool { // 语义上不会与 Write 的调用范式混淆。 func execRecordMessage(path string, size, lines int) ChatMessage { return ChatMessage{ - Role: "user", - Content: fmt.Sprintf("[Write 执行记录]\n工具: Write\n路径: %s\n状态: 成功\n大小: %d 字节\n行数: %d", - path, size, lines), + Role: "user", + Content: fmt.Sprintf("[Write 执行记录]\n工具: Write\n路径: %s\n状态: 成功\n大小: %d 字节\n行数: %d", path, size, lines), + IsExecRecord: true, // 系统注入,非用户所说:渲染/轮数统计应跳过 } } diff --git a/tui/model.go b/tui/model.go index a01d4cc..6c7b53e 100644 --- a/tui/model.go +++ b/tui/model.go @@ -3435,6 +3435,10 @@ func chatDisplayText(msg agent.ChatMessage) string { func rebuildChatFromHistory(cl *chatLog, history []agent.ChatMessage) { for _, msg := range history { + // 系统注入的执行记录(role=user 但非用户所说)不渲染为用户气泡。 + if msg.IsExecRecord { + continue + } switch msg.Role { case "user": if t := chatDisplayText(msg); t != "" { From 8df79dc9f3b3d352a829f444b22b50a2c75936ca Mon Sep 17 00:00:00 2001 From: yyz159756 Date: Wed, 5 Aug 2026 17:05:32 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?=E7=AC=A6=E6=A3=80=E6=B5=8B=E5=8E=BB=E6=8E=89=E9=95=BF=E5=BA=A6?= =?UTF-8?q?=E9=97=A8=E6=A7=9B,=E5=91=BD=E4=B8=AD=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E5=8D=B3=E6=8B=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/write_file.go | 23 +++++++++-------------- tools/write_file_test.go | 7 ++++++- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tools/write_file.go b/tools/write_file.go index b779753..80a807a 100644 --- a/tools/write_file.go +++ b/tools/write_file.go @@ -9,11 +9,6 @@ import ( "strings" ) -// minPlaceholderCheckBytes:content 短于该字节数时才做占位符模式检测。 -// 正常的小文件(如几字节配置)很少短于 32 字节,误判率低;长内容即使含个别 -// 占位符字样也可能是真实文本,不拦(避免误伤)。 -const minPlaceholderCheckBytes = 32 - // placeholderPatterns:疑似「缺失值/占位标记」的文本模式(小写匹配)。 // 命中说明模型很可能把元描述/占位符当 content 写入了(上下文污染),应拒绝并提示重写。 // 注意:错误提示里刻意不回显这些具体字符串 —— 错误信息会进模型上下文, @@ -26,7 +21,9 @@ var placeholderPatterns = []string{ // validateWriteContent 写入前校验 content 是否为疑似占位符/缺失值文本: // - 空内容 → 拒绝(写空文件请用其他方式,如 python 建空文件); -// - 内容过短且命中占位符模式 → 拒绝。 +// - 命中占位符模式 → 拒绝(**不设长度门槛**):最该拦的折叠文本(如 "[已写入 ...]" 61~70 +// 字节)远超旧阈值 32 字节,长度门槛会把它放过去;模式表本身足够精确 +// (/[已写入 不会出现在真实代码),去掉门槛不增加实际误伤。 // // 错误提示只描述性质、给修正方向,不回显占位符文本。 func validateWriteContent(content string) error { @@ -35,14 +32,12 @@ func validateWriteContent(content string) error { "若要创建空文件,请用 python -c \"open('目标路径','w').close()\"(Windows 无 touch)," + "或提供真实内容") } - if len(content) < minPlaceholderCheckBytes { - low := strings.ToLower(content) - for _, p := range placeholderPatterns { - if strings.Contains(low, p) { - return fmt.Errorf("Write 拒绝: content 疑似缺失值/占位标记,而非真实文件内容。" + - "这通常是上下文污染导致模型把元描述当内容输出。请重新提供完整的真实内容;" + - "若确需写入极短内容,请补充上下文或调整写法") - } + low := strings.ToLower(content) + for _, p := range placeholderPatterns { + if strings.Contains(low, p) { + return fmt.Errorf("Write 拒绝: content 疑似缺失值/占位标记,而非真实文件内容。" + + "这通常是上下文污染导致模型把元描述当内容输出。请重新提供完整的真实内容;" + + "若确需写入极短内容,请补充上下文或调整写法") } } return nil diff --git a/tools/write_file_test.go b/tools/write_file_test.go index bad919e..c91e65d 100644 --- a/tools/write_file_test.go +++ b/tools/write_file_test.go @@ -11,7 +11,7 @@ func TestValidateWriteContent_Valid(t *testing.T) { cases := []string{ strings.Repeat("hello world\n", 10), // 正常长内容 "hello", // 极短但正常,不命中占位符 - strings.Repeat("a", 100) + "marker" + strings.Repeat("b", 100), // 长内容即使含个别字样也放行 + strings.Repeat("a", 100) + "marker" + strings.Repeat("b", 100), // 长内容含非占位符字样放行 } for _, c := range cases { if err := validateWriteContent(c); err != nil { @@ -32,6 +32,11 @@ func TestValidateWriteContent_Rejected(t *testing.T) { "[参数已折叠", "content omitted", "内容已省略", + // 长占位符(实测 61~70 字节)也必须拒 —— 去掉长度门槛后命中模式即拒, + // 不再因为"超 32 字节"而放行最该拦的折叠文本。 + "[已写入 config.yaml,1247 字节/42 行;需要内容用 Read 查看]", + "[已写入 a.go,100 字节/5 行;需要内容用 Read 查看]", + "这是一个很长的描述:内容已省略,请用 Read 查看原文,这里写的是摘要信息", } for _, c := range cases { if err := validateWriteContent(c); err == nil { From 9f7d02cfcd2ae6045c27a91019aa9c5370e71bf6 Mon Sep 17 00:00:00 2001 From: yyz159756 Date: Wed, 5 Aug 2026 17:05:58 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=E7=A9=BA=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E6=94=BE=E8=A1=8C,=E5=8F=AA=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E7=AC=A6=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/write_file.go | 12 ++++-------- tools/write_file_test.go | 21 ++++++++++++--------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/tools/write_file.go b/tools/write_file.go index 80a807a..c35e63e 100644 --- a/tools/write_file.go +++ b/tools/write_file.go @@ -20,18 +20,14 @@ var placeholderPatterns = []string{ } // validateWriteContent 写入前校验 content 是否为疑似占位符/缺失值文本: -// - 空内容 → 拒绝(写空文件请用其他方式,如 python 建空文件); -// - 命中占位符模式 → 拒绝(**不设长度门槛**):最该拦的折叠文本(如 "[已写入 ...]" 61~70 +// - 命中占位符模式 → 拒绝(不设长度门槛):最该拦的折叠文本(如 "[已写入 ...]" 61~70 // 字节)远超旧阈值 32 字节,长度门槛会把它放过去;模式表本身足够精确 -// (/[已写入 不会出现在真实代码),去掉门槛不增加实际误伤。 +// (/[已写入 不会出现在真实代码),去掉门槛不增加实际误伤; +// - 空内容 → 放行:建空文件(__init__.py/.gitkeep)是合法需求,plan 模式只读工具集 +// 无 Bash、建不了空文件 —— 拒绝空内容属于功能回退,启发式防护不值这个代价。 // // 错误提示只描述性质、给修正方向,不回显占位符文本。 func validateWriteContent(content string) error { - if strings.TrimSpace(content) == "" { - return fmt.Errorf("Write 拒绝: content 为空。Write 的 content 必须是完整文件内容;" + - "若要创建空文件,请用 python -c \"open('目标路径','w').close()\"(Windows 无 touch)," + - "或提供真实内容") - } low := strings.ToLower(content) for _, p := range placeholderPatterns { if strings.Contains(low, p) { diff --git a/tools/write_file_test.go b/tools/write_file_test.go index c91e65d..1ce9bd2 100644 --- a/tools/write_file_test.go +++ b/tools/write_file_test.go @@ -12,6 +12,8 @@ func TestValidateWriteContent_Valid(t *testing.T) { strings.Repeat("hello world\n", 10), // 正常长内容 "hello", // 极短但正常,不命中占位符 strings.Repeat("a", 100) + "marker" + strings.Repeat("b", 100), // 长内容含非占位符字样放行 + "", // 空内容放行(建空文件合法,__init__.py/.gitkeep) + " \n ", // 纯空白也放行(不因 TrimSpace 拒绝) } for _, c := range cases { if err := validateWriteContent(c); err != nil { @@ -22,8 +24,6 @@ func TestValidateWriteContent_Valid(t *testing.T) { func TestValidateWriteContent_Rejected(t *testing.T) { cases := []string{ - "", - " \n ", "", "", "", @@ -57,14 +57,17 @@ func TestWriteFile_RejectsPlaceholder(t *testing.T) { } } -func TestValidateWriteContent_EmptyMessageMentionsPython(t *testing.T) { - // 空内容提示应引导 python 而非 touch(Windows 无 touch)。 - err := validateWriteContent("") - if err == nil { - t.Fatalf("空内容应被拒绝") +func TestWriteFile_AllowsEmptyContent(t *testing.T) { + // 空内容放行:建空文件(__init__.py/.gitkeep)是合法需求,plan 模式无 Bash 也能建。 + dir := t.TempDir() + p := filepath.Join(dir, "__init__.py") + res := WriteFile(map[string]any{"path": p, "content": ""}) + if !res.Success { + t.Fatalf("空内容应写入成功, got=%+v", res) } - if !strings.Contains(err.Error(), "python") { - t.Fatalf("空内容提示应给出 python 建空文件方法, got=%q", err.Error()) + b, err := os.ReadFile(p) + if err != nil || len(b) != 0 { + t.Fatalf("空文件应写入 0 字节, err=%v len=%d", err, len(b)) } } From 6a60902c1457d5fee499954dd2346a2ef81840e6 Mon Sep 17 00:00:00 2001 From: yyz159756 Date: Wed, 5 Aug 2026 17:18:47 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E2=9C=85=20test:=20=E5=85=AD=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E9=80=90=E4=B8=80=E8=A1=A5=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/elision_test.go | 102 ++++++++++++++++++++++++++++++++++++++++++ agent/llm.go | 48 +++++++++++++------- 2 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 agent/elision_test.go diff --git a/agent/elision_test.go b/agent/elision_test.go new file mode 100644 index 0000000..5d84e89 --- /dev/null +++ b/agent/elision_test.go @@ -0,0 +1,102 @@ +package agent + +import ( + "strings" + "testing" +) + +// 问题 1:执行记录插入破坏 assistant/tool 配对 —— 摘除逻辑只摘成功折叠项,配对保持完整。 +func TestStripElidedToolCalls_PairIntegrity(t *testing.T) { + // 混合批次:大 Write(成功折叠)+ Read(正常保留)。 + big := `{"path":"big.go","content":` + jsonStr(strings.Repeat("a", 4000)) + `}` + tcs := []ToolCall{ + mkTCID("call_write", "Write", big), + mkTCID("call_read", "Read", `{"path":"r.go"}`), + } + // 执行后:大 Write 成功 → 从 assistant tool_calls 摘除;Read 保留。 + // 历史里 assistant 剩 [Read],其 tool 消息正常配对 → 不悬挂、不 400。 + kept := stripElidedToolCalls(tcs, []string{"call_write"}) + if len(kept) != 1 { + t.Fatalf("应只剩 Read, got %d 条", len(kept)) + } + if kept[0].Function.Name != "Read" || kept[0].ID != "call_read" { + t.Fatalf("应保留 Read(call_read), got %s(%s)", kept[0].Function.Name, kept[0].ID) + } + // 配对完整性:assistant 剩余 tool_call 的每个 ID,都必须有对应的 tool 消息(模拟结果)。 + results := map[string]bool{"call_read": true} // Read 的工具结果 + for _, tc := range kept { + if !results[tc.ID] { + t.Fatalf("剩余 tool_call %s 无对应 tool 结果 → 悬挂", tc.ID) + } + } +} + +// 问题 2:失败的大 Write 不被吞 —— 失败项不在 successIDs,保留 tool_call 供错误配对。 +func TestStripElidedToolCalls_FailedWritePreserved(t *testing.T) { + big := `{"path":"fail.go","content":` + jsonStr(strings.Repeat("b", 4000)) + `}` + tcs := []ToolCall{ + mkTCID("call_fail", "Write", big), + mkTCID("call_read", "Read", `{"path":"r.go"}`), + } + // 大 Write 失败 → 不进 successIDs;只摘除成功项(无)。 + kept := stripElidedToolCalls(tcs, nil) + if len(kept) != 2 { + t.Fatalf("失败 Write 应保留, got %d 条", len(kept)) + } + // 失败 Write 的 tool 错误消息能与保留的 tool_call 配对(assistant 仍有该调用)。 + if kept[0].Function.Name != "Write" || kept[0].ID != "call_fail" || kept[1].Function.Name != "Read" { + t.Fatalf("应保留 [失败Write, Read], got %s(%s),%s(%s)", kept[0].Function.Name, kept[0].ID, kept[1].Function.Name, kept[1].ID) + } + // 失败 Write 有对应 tool 错误消息 → 配对、错误透传。 + results := map[string]bool{"call_fail": true, "call_read": true} + for _, tc := range kept { + if !results[tc.ID] { + t.Fatalf("剩余 tool_call %s 无对应 tool 结果 → 悬挂", tc.ID) + } + } +} + +// mkTCID 构造指定 ID 的工具调用(测试需要区分多个 tool_call,mkTC 固定 id1 不可用)。 +func mkTCID(id, name, argsJSON string) ToolCall { + return ToolCall{ID: id, Type: "function", Function: ToolCallFunc{Name: name, Arguments: argsJSON}} +} + +// 问题 3:elided 判定缓存一致 —— 截断 args 经修复后判定 ok,且记录内容非空; +// 对比原始截断 args 直接判定 !ok,证明"两处各算一次"会产生空路径/0 字节记录。 +func TestCollectElided_Consistent(t *testing.T) { + // 模拟模型吐出的截断 arguments:content 被截成半截(issue #201 典型场景)。 + truncated := `{"path":"t.go","content":"` + strings.Repeat("x", 2000) + `"` + // 原始截断 args:JSON 不完整 → elidedWriteInfo 判定失败(ok=false)。 + if _, _, _, ok := elidedWriteInfo(truncated); ok { + t.Fatalf("原始截断 args 应判定 !ok") + } + // 修复后 args(repairArgsJSON 补全)→ 判定 ok,且缓存记录非空。 + repaired := repairArgsJSON(truncated) + elided := collectElided([]ToolCall{mkTC("Write", repaired)}) + if len(elided) != 1 { + t.Fatalf("修复后 args 应判定为 elide, got %d 条", len(elided)) + } + for id, ew := range elided { + if ew.path == "" || ew.size == 0 || ew.lines == 0 { + t.Fatalf("缓存记录不应为空路径/0 字节: id=%s path=%q size=%d lines=%d", id, ew.path, ew.size, ew.lines) + } + } + // 两处共用同一份缓存 → 执行循环不再用原始 args 二次判定(避免空记录)。 + _ = elided +} + +// 问题 4:执行记录(role=user)不计对话轮边界 —— isTurnBoundary 过滤 IsExecRecord。 +func TestIsTurnBoundary_ExecRecord(t *testing.T) { + if isTurnBoundary(ChatMessage{Role: "user", IsExecRecord: true}) { + t.Fatalf("执行记录不应算作轮边界") + } + if !isTurnBoundary(ChatMessage{Role: "user"}) { + t.Fatalf("普通 user 消息应算轮边界") + } + if !isTurnBoundary(ChatMessage{Role: "assistant"}) { + t.Fatalf("assistant 消息应算轮边界") + } + if isTurnBoundary(ChatMessage{Role: "tool"}) { + t.Fatalf("tool 消息不应算轮边界") + } +} diff --git a/agent/llm.go b/agent/llm.go index 3455156..6443685 100644 --- a/agent/llm.go +++ b/agent/llm.go @@ -849,14 +849,7 @@ func StartStream( histToolCalls := rewriteToolCallArgsForHistory(toolCalls) // elided:大 content Write 的判定结果缓存(问题 3:只算一次,避免组装用修复后 args、 // 执行用原始 args 两次判定不一致 → 空路径/0 字节记录)。 - elided := make(map[string]elidedWrite) - for _, tc := range histToolCalls { - if tc.Function.Name == "Write" { - if p, s, l, ok := elidedWriteInfo(tc.Function.Arguments); ok { - elided[tc.ID] = elidedWrite{path: p, size: s, lines: l} - } - } - } + elided := collectElided(histToolCalls) assistantIdx := len(convo) convo = append(convo, ChatMessage{ Role: "assistant", @@ -1173,14 +1166,7 @@ func StartStream( } // 摘除成功的大 Write tool_call(它们无对应 tool 消息,保留会悬挂;失败项保留配对)。 if len(successElidedIDs) > 0 { - kept := convo[assistantIdx].ToolCalls[:0:0] - for _, tc := range convo[assistantIdx].ToolCalls { - if containsID(successElidedIDs, tc.ID) { - continue - } - kept = append(kept, tc) - } - convo[assistantIdx].ToolCalls = kept + convo[assistantIdx].ToolCalls = stripElidedToolCalls(convo[assistantIdx].ToolCalls, successElidedIDs) } // 折叠 Write 的执行记录统一在所有 tool 消息之后追加(协议安全,见 pendingExecRecords 注释)。 convo = append(convo, pendingExecRecords...) @@ -1897,6 +1883,36 @@ type elidedWrite struct { lines int } +// collectElided 组装阶段:从(repairArgsJSON 修复后的)toolCalls 提取大 Write 的折叠判定缓存。 +// 组装与执行循环共用同一份结果,避免"修复后 args 判定 ok、原始 args 判定 !ok"的不一致 +// (问题 3:那会导致空路径/0 字节的"成功"执行记录)。 +func collectElided(tcs []ToolCall) map[string]elidedWrite { + elided := make(map[string]elidedWrite) + for _, tc := range tcs { + if tc.Function.Name == "Write" { + if p, s, l, ok := elidedWriteInfo(tc.Function.Arguments); ok { + elided[tc.ID] = elidedWrite{path: p, size: s, lines: l} + } + } + } + return elided +} + +// stripElidedToolCalls 执行阶段:从 assistant tool_calls 中摘除【成功】折叠的 Write +// (它们没有对应 tool 消息,保留会悬挂 tool_call → 严格后端 400); +// 其余(失败的大 Write / Read / Update 等)保留 —— 失败 Write 的 tool 错误消息 +// 与保留的 tool_call 正常配对,错误透传给模型(问题 1/2)。 +func stripElidedToolCalls(tcs []ToolCall, successIDs []string) []ToolCall { + out := tcs[:0:0] + for _, tc := range tcs { + if containsID(successIDs, tc.ID) { + continue + } + out = append(out, tc) + } + return out +} + // containsID 判断 ID 是否在集合中。 func containsID(ids []string, id string) bool { for _, x := range ids {