Skip to content

Commit b22418c

Browse files
committed
Discard corrupted GLM tool calls instead of executing them
The wizard-ai gateway (aicoder-pro, GLM 5.2) sometimes fails to convert GLM tool-call template markup into structured tool_calls under load, and returns names like `skill_view(name="kimi-webbridge")</arg_value>` or an empty name with `{}`. ACECode executed them as Unknown tool, wrote the broken name back into history and echoed it in the error, so the model copied it again (session 20261014-081557-766b looped for 13 rounds). - Treat a step as corrupted output when a tool call has an empty name, a non-conventional name carrying GLM markers or call syntax, or arguments carrying template structure; reuse the existing discard and retry-once path (response_corrupted_retry gains a context param). - Do not echo empty or corrupted names in Unknown tool errors. - History projection drops corrupted-name calls and turns their persisted error results into a hidden "tool call not executed" user note, so the request no longer ends on an orphaned assistant message. - Describe skill_view / memory_read calls in prose instead of pseudo function-call syntax in prompts and tool hints.
1 parent f4dd8e7 commit b22418c

26 files changed

Lines changed: 1091 additions & 35 deletions

‎CLAUDE.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@ generates one standard-quality image and must never run on load or save.
150150

151151
[AgentLoop](src/engine/agent/agent_loop.hpp) is the public facade. [TurnRunner](src/engine/agent/turn/turn_runner.cpp) owns the multi-turn state machine; [TurnFinalizer](src/engine/agent/turn/turn_finalizer.cpp) owns ordered completion. Fixed dependencies enter through [AgentLoopServices](src/engine/agent/agent_loop_services.hpp); event-backed prompters are installed before explicit start(). A text-only assistant reply ends the loop. `task_complete` is an optional explicit terminator that renders a concise completion row. `AskUserQuestion` is not a terminator; its answer returns as a tool result. `config.agent_loop.max_iterations` is an optional hard cap; `0` or omitted means unlimited.
152152

153-
**输出损坏重发一次(`<arg_value>`)。** 回复正文里(代码块与行内代码之外)出现工具参数模板标记 `<arg_key>` / `<arg_value>` 及闭合标签(`find_leaked_tool_argument_markup`),说明网关把模板残片吐进了输出流,这一步的正文与同一回复里的原生工具调用都不可信:TurnRunner 在执行工具之前整条丢弃(不入历史、不执行)、清掉 TUI / Web 的流式草稿、发 `response_corrupted_retry` 通知后原样重发;每步连续最多 1 次(`ResponseRecoveryState::corrupted_output_retries`,产出有效工具批次后清零),重发后仍损坏就按原流程继续。`</think>` 刻意不算 —— 把推理写进正文的模型会合法输出它(用户拍板:think 残片不处理)。起因:反馈 huangyuan816,坏回复里的 bash 命令也夹着乱码照样执行,之后整个回合答非所问。
153+
**输出损坏重发一次(`<arg_value>`、坏工具调用)。** 两类都说明网关把模板残片吐进了输出流,这一步的正文与同一回复里的全部工具调用都不可信:(1) 回复正文里(代码块与行内代码之外)出现工具参数模板标记 `<arg_key>` / `<arg_value>` 及闭合标签(`find_leaked_tool_argument_markup`);(2) 工具调用本身坏了([tool_call_corruption.hpp](src/domain/llm/tool_call_corruption.hpp)):名字为空,或名字不符合 `^[A-Za-z0-9_-]{1,64}$` 且夹着 GLM 模板标记(`<tool_call>` / `<arg_key>` / `<arg_value>` 及闭合)或调用语法(括号、方括号、引号、等号),已注册的名字不按名字判;参数只认模板残片的**结构**(值以 `</arg_value>` 结尾、`</arg_key>` 后紧跟 `<arg_value>`、`</arg_value>` 后紧跟下一段模板、键名带标记、非 JSON 且含标记)。**不要**把参数检查改成子串一出现就拦:编辑本来就含这些标记的文件(ACECode 自己的源码和测试)会被每次丢弃重发、重发后照样命中而报错停下。命中后:TurnRunner 在执行工具之前整条丢弃(不入历史、不执行)、清掉 TUI / Web 的流式草稿、发 `response_corrupted_retry` 通知后原样重发(工具调用损坏的通知带 `context` = `tool_name_empty` / `tool_name` / `tool_arguments`,Web 按 i18next context 选 `response_corrupted_retry_<context>` 文案);每步连续最多 1 次(`ResponseRecoveryState::corrupted_output_retries`,产出有效工具批次后清零),重发后仍损坏就报错停止。格式正常但不存在的名字(`Bash`、`functions.x`)不算损坏,照旧走 Unknown tool;`ToolErrors::unknown_tool` 对空名字 / 损坏名字不回显原名。`</think>` 刻意不算 —— 把推理写进正文的模型会合法输出它(用户拍板:think 残片不处理)。起因:反馈 huangyuan816,坏回复里的 bash 命令也夹着乱码照样执行,之后整个回合答非所问;内网 wizard-ai 网关 aicoder-pro(GLM 5.2 FP8,2026-10-14)高峰期没能把模型写的工具调用模板转成结构化调用,`skill_view(name="kimi-webbridge")</arg_value>` 这类名字被当成 Unknown tool 执行,坏名字连同回显它的报错写回历史,又被服务端按模板排回提示词,会话 20261014-081557-766b 连续空转 13 轮。
154+
155+
旧会话里已经落盘的坏调用由历史投影兜底(`recover_provider_history`,幂等、只由内容决定):空名 / 损坏名的调用从投影里去掉;它的报错结果换成一条 `kMalformedToolCallNote`(user 角色 + `hidden_goal_context`,放在这组工具结果之后,相邻的合并成一条),请求不再以孤立的 assistant 消息结尾,模型也看得到调用失败;没有落盘结果的坏调用照旧只去掉。转换数记进 `[session-recovery]` 日志的 `malformed_results`。提示词与工具输出里不要写 `skill_view(name="…")`、`memory_read(name=...)` 这类伪函数调用 —— GLM 会把整段表达式照抄成工具名,一律用文字描述(「调用 skill_view 工具,name 参数填技能名」)。回归:`tests/llm/tool_call_corruption_test.cpp`、`agent_loop_termination_test.cpp` 的 `Corrupted*` / `EmptyToolName*` / `ArgumentsQuotingTemplateMarkersStillExecute`、`session_history_recovery_test.cpp` 的 `*NotExecutedNote*` 组、`tool_executor_name_resolution_test.cpp::UnknownToolErrorDoesNotEchoCorruptedNames`,前端 `systemNotice.test.js`。
154156

155157
`agent_loop.question_policy`(`ask` 默认 / `deny` / `timeout`,CLI `--question-policy` 覆盖只写运行时字段不落盘)控制 AskUserQuestion 应答:deny 不弹 UI 直接返回「自行决策并继续」自动应答;timeout 等 `question_timeout_seconds`(默认 60,[5,3600])秒后自动采纳每题第一选项(output/metadata 标注自动采纳)。YOLO 只跳过工具权限确认,不改变提问策略;active goal 将每次 AskUserQuestion 覆盖为 30 秒 timeout,正常弹 UI,超时采纳推荐项。daemon 通过 `AskUserQuestionPrompter::prompt` 的 per-call timeout override 实现 goal 动态覆盖。见 openspec/changes/add-ask-question-policy。
156158

‎src/adapters/tool/memory_read_tool.cpp‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include "memory_read_tool.hpp"
22

33
#include "memory_tool_scope.hpp"
4+
#include "llm/tool_protocol_names.hpp"
45

56
#include "memory/memory_service.hpp"
67
#include "memory/memory_types.hpp"
@@ -165,7 +166,9 @@ ToolImpl create_memory_read_tool(std::shared_ptr<MemoryService> memory) {
165166
}
166167
out["found"] = false;
167168
out["name"] = name;
168-
out["hint"] = "Use memory_read() with no args to list available entries.";
169+
// 文字描述调用方式,不写伪函数调用(GLM 系模型会把它照抄成工具名)。
170+
out["hint"] = "Call " + model_tool_name_for_native("memory_read") +
171+
" without arguments to list available entries.";
169172
LOG_DEBUG("[memory_read] entry not found: " + name);
170173
return ToolResult{out.dump(), true};
171174
}

‎src/adapters/tool/skills_tool.cpp‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include "skills_tool.hpp"
22

33
#include "config/config.hpp"
4+
#include "llm/tool_protocol_names.hpp"
45
#include "skills/skill_init.hpp"
56
#include "skills/skill_registry.hpp"
67
#include "utils/logger.hpp"
@@ -170,7 +171,10 @@ ToolImpl create_skills_list_tool(SkillRegistry& registry,
170171
? "No skills installed. Add SKILL.md files under ~/.acecode/skills/ or ~/.agent/skills/."
171172
: ("No skills in category '" + normalized_category + "'.");
172173
} else {
173-
out["hint"] = "Use skill_view(name=...) to load the full content of a skill.";
174+
// 文字描述调用方式,不写伪函数调用(GLM 系模型会把它照抄成工具名)。
175+
out["hint"] = "To load a skill's full content, call the " +
176+
model_tool_name_for_native("skill_view") +
177+
" tool with its name argument set to the skill name.";
174178
}
175179

176180
LOG_DEBUG("[skills_list] requested_category='" + requested_category +

‎src/adapters/tool/tool_errors.hpp‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#pragma once
22

3+
#include "llm/tool_call_corruption.hpp"
34
#include "llm/tool_protocol_names.hpp"
45

56
#include <string>
@@ -165,9 +166,21 @@ class ToolErrors {
165166
// 模型侧工具名(AgentLoop 从 bundle.tool_defs 取),列出来让模型照抄;
166167
// 为空(未接线)时回退旧文案。最多列 kMaxListed 个,超出写 (+N more)
167168
// —— 54 个工具的请求实测存在,100 足以全列,再多只会把结果撑成噪音。
169+
// 名字为空或已损坏(调用表达式、夹着模板标记,判定见 llm/tool_call_corruption)
170+
// 时不回显:报错会写进历史,原样回显等于再给模型看一遍错误示范(内网 GLM
171+
// 2026-10-14,`skill_view(name="…")</arg_value>` 隔两轮又被照抄出来)。
168172
static std::string unknown_tool(const std::string& name,
169173
const std::vector<std::string>& available) {
170-
std::string msg = "[Error] Unknown tool: " + name;
174+
std::string msg;
175+
if (const auto corruption = diagnose_corrupted_tool_name(name)) {
176+
msg = corruption->reason == ToolCallCorruption::Reason::EmptyName
177+
? "[Error] The tool call had no tool name, so nothing was executed"
178+
: "[Error] Unknown tool: the requested tool name was malformed (it "
179+
"contained call syntax or template markup instead of a plain tool "
180+
"name), so it is not repeated here";
181+
} else {
182+
msg = "[Error] Unknown tool: " + name;
183+
}
171184
if (available.empty()) return msg;
172185
constexpr std::size_t kMaxListed = 100;
173186
msg += ". Available tools: ";

‎src/adapters/tool/tool_executor.cpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,7 @@ ToolResult ToolExecutor::execute(const std::string& tool_name, const std::string
447447
auto it = tools_.find(tool_name);
448448
if (it == tools_.end()) {
449449
LOG_ERROR("execute: unknown tool '" + tool_name + "'");
450-
ToolResult result{"[Error] Unknown tool: " + tool_name, false};
450+
ToolResult result{ToolErrors::unknown_tool(tool_name, {}), false};
451451
ensure_tool_summary(tool_name, arguments_json, result);
452452
return result;
453453
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
#include "tool_call_corruption.hpp"
2+
3+
#include <nlohmann/json.hpp>
4+
5+
namespace acecode {
6+
7+
namespace {
8+
9+
constexpr std::string_view kToolCallOpen = "<tool_call>";
10+
constexpr std::string_view kToolCallClose = "</tool_call>";
11+
constexpr std::string_view kArgKeyOpen = "<arg_key>";
12+
constexpr std::string_view kArgKeyClose = "</arg_key>";
13+
constexpr std::string_view kArgValueOpen = "<arg_value>";
14+
constexpr std::string_view kArgValueClose = "</arg_value>";
15+
constexpr std::string_view kGlmMarkers[] = {
16+
kToolCallOpen, kToolCallClose, kArgKeyOpen, kArgKeyClose, kArgValueOpen, kArgValueClose};
17+
18+
// 名字里出现这些字符,说明模型把调用表达式当成了名字:
19+
// skill_view(name="x")、skill_view["x"]、{"name": …}。
20+
constexpr std::string_view kCallSyntaxChars = "()[]{}\"'=<>`";
21+
22+
constexpr std::string_view kWhitespace = " \t\r\n";
23+
24+
bool is_blank(std::string_view text) {
25+
return text.find_first_not_of(kWhitespace) == std::string_view::npos;
26+
}
27+
28+
// pos 起跳过空白后是否以 expected 开头。
29+
bool followed_by(std::string_view text, std::size_t pos, std::string_view expected) {
30+
const auto next = text.find_first_not_of(kWhitespace, pos);
31+
return next != std::string_view::npos && text.compare(next, expected.size(), expected) == 0;
32+
}
33+
34+
// 字符串值里的模板结构,命中返回结构开头的标记。规则见头文件。
35+
std::optional<std::string> find_template_structure(std::string_view value) {
36+
for (auto pos = value.find(kArgKeyClose); pos != std::string_view::npos;
37+
pos = value.find(kArgKeyClose, pos + 1)) {
38+
if (followed_by(value, pos + kArgKeyClose.size(), kArgValueOpen)) {
39+
return std::string(kArgKeyClose);
40+
}
41+
}
42+
for (auto pos = value.find(kArgValueClose); pos != std::string_view::npos;
43+
pos = value.find(kArgValueClose, pos + 1)) {
44+
const std::size_t after = pos + kArgValueClose.size();
45+
if (is_blank(value.substr(after)) || followed_by(value, after, kArgKeyOpen) ||
46+
followed_by(value, after, kToolCallOpen) || followed_by(value, after, kToolCallClose)) {
47+
return std::string(kArgValueClose);
48+
}
49+
}
50+
return std::nullopt;
51+
}
52+
53+
std::optional<std::string> find_corrupted_json_text(const nlohmann::json& value) {
54+
if (value.is_string()) return find_template_structure(value.get_ref<const std::string&>());
55+
if (value.is_object()) {
56+
for (auto it = value.begin(); it != value.end(); ++it) {
57+
if (auto marker = find_glm_tool_call_marker(it.key())) return marker;
58+
if (auto marker = find_corrupted_json_text(it.value())) return marker;
59+
}
60+
} else if (value.is_array()) {
61+
for (const auto& item : value) {
62+
if (auto marker = find_corrupted_json_text(item)) return marker;
63+
}
64+
}
65+
return std::nullopt;
66+
}
67+
68+
} // namespace
69+
70+
bool is_conventional_tool_name(std::string_view name) {
71+
if (name.empty() || name.size() > 64) return false;
72+
for (const char c : name) {
73+
const bool allowed = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
74+
(c >= '0' && c <= '9') || c == '_' || c == '-';
75+
if (!allowed) return false;
76+
}
77+
return true;
78+
}
79+
80+
std::optional<std::string> find_glm_tool_call_marker(std::string_view text) {
81+
std::size_t earliest = std::string_view::npos;
82+
std::string_view found;
83+
for (const auto marker : kGlmMarkers) {
84+
const auto pos = text.find(marker);
85+
if (pos < earliest) {
86+
earliest = pos;
87+
found = marker;
88+
}
89+
}
90+
if (earliest == std::string_view::npos) return std::nullopt;
91+
return std::string(found);
92+
}
93+
94+
std::optional<ToolCallCorruption> diagnose_corrupted_tool_name(std::string_view name) {
95+
using Field = ToolCallCorruption::Field;
96+
using Reason = ToolCallCorruption::Reason;
97+
if (is_blank(name)) return ToolCallCorruption{Field::Name, Reason::EmptyName, {}};
98+
if (is_conventional_tool_name(name)) return std::nullopt;
99+
if (auto marker = find_glm_tool_call_marker(name)) {
100+
return ToolCallCorruption{Field::Name, Reason::TemplateMarkup, std::move(*marker)};
101+
}
102+
const auto syntax = name.find_first_of(kCallSyntaxChars);
103+
if (syntax != std::string_view::npos) {
104+
return ToolCallCorruption{Field::Name, Reason::CallSyntax, std::string(1, name[syntax])};
105+
}
106+
return std::nullopt;
107+
}
108+
109+
std::optional<ToolCallCorruption> diagnose_corrupted_tool_arguments(std::string_view arguments) {
110+
// 快路径:绝大多数参数里连标签名都没有(file_write 的参数可能有几百 KB)。
111+
if (arguments.find("arg_") == std::string_view::npos &&
112+
arguments.find("tool_call>") == std::string_view::npos) {
113+
return std::nullopt;
114+
}
115+
const auto parsed = nlohmann::json::parse(arguments, nullptr, false);
116+
std::optional<std::string> marker = parsed.is_discarded()
117+
? find_glm_tool_call_marker(arguments)
118+
: find_corrupted_json_text(parsed);
119+
if (!marker) return std::nullopt;
120+
return ToolCallCorruption{ToolCallCorruption::Field::Arguments,
121+
ToolCallCorruption::Reason::TemplateMarkup, std::move(*marker)};
122+
}
123+
124+
std::optional<CorruptedToolCall> find_corrupted_tool_call(
125+
const std::vector<ToolCall>& calls,
126+
const std::function<bool(const std::string&)>& is_registered) {
127+
for (std::size_t i = 0; i < calls.size(); ++i) {
128+
const auto& call = calls[i];
129+
if (!is_registered || !is_registered(call.function_name)) {
130+
if (auto corruption = diagnose_corrupted_tool_name(call.function_name)) {
131+
return CorruptedToolCall{i, std::move(*corruption)};
132+
}
133+
}
134+
if (auto corruption = diagnose_corrupted_tool_arguments(call.function_arguments)) {
135+
return CorruptedToolCall{i, std::move(*corruption)};
136+
}
137+
}
138+
return std::nullopt;
139+
}
140+
141+
} // namespace acecode
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#pragma once
2+
3+
// 损坏工具调用判定。纯字符串 / JSON 逻辑,无 IO:TurnRunner 据此整步丢弃重发,
4+
// ToolErrors 据此决定 Unknown tool 报错回不回显名字,发给模型的历史投影据此
5+
// 藏起旧会话里的坏调用 —— 三处同一口径。
6+
//
7+
// 背景(内网 wizard-ai 网关 aicoder-pro = GLM 5.2 FP8,2026-10-14 daemon 日志):
8+
// GLM 把工具调用写进聊天模板(`<tool_call>名字` 加成对的 `<arg_key>` /
9+
// `<arg_value>`),由服务端解析成结构化 tool_calls。高峰期解析失败,返回的函数名
10+
// 变成 `skill_view(name="kimi-webbridge")</arg_value>`、几段调用串在同一个名字
11+
// 里,或者干脆是空名字 + `{}`。这类调用执行了也只会报 Unknown tool,坏名字还会
12+
// 写回历史,下一轮被服务端按模板排回提示词,等于给模型看错误示范。
13+
14+
#include "llm/llm_provider.hpp"
15+
16+
#include <cstddef>
17+
#include <functional>
18+
#include <optional>
19+
#include <string>
20+
#include <string_view>
21+
#include <vector>
22+
23+
namespace acecode {
24+
25+
// 工具名字符集 ^[A-Za-z0-9_-]{1,64}$(与「工具重写」模型侧名同一规则,也是
26+
// OpenAI / Anthropic 对 function name 的约束)。
27+
bool is_conventional_tool_name(std::string_view name);
28+
29+
// 文本里最早出现的 GLM 工具调用模板标记(<tool_call> </tool_call> <arg_key>
30+
// </arg_key> <arg_value> </arg_value>),没有返回空。
31+
std::optional<std::string> find_glm_tool_call_marker(std::string_view text);
32+
33+
struct ToolCallCorruption {
34+
enum class Field { Name, Arguments };
35+
enum class Reason { EmptyName, TemplateMarkup, CallSyntax };
36+
Field field = Field::Name;
37+
Reason reason = Reason::EmptyName;
38+
// 命中的模板标记或调用语法字符;空名字时为空。
39+
std::string marker;
40+
};
41+
42+
// 工具名损坏:名字为空(或只有空白);或不符合工具名字符集,且含 GLM 模板标记
43+
// 或调用语法(圆括号、方括号、花括号、引号、等号、尖括号、反引号)。格式正常但
44+
// 不存在的名字(`Bash`、`functions.bash`)不算,那走 Unknown tool,报错会列出
45+
// 可用工具。已注册的工具名由调用方先排除。
46+
std::optional<ToolCallCorruption> diagnose_corrupted_tool_name(std::string_view name);
47+
48+
// 参数损坏:只认模板残片的结构,不认单个标记。模型要编辑的文件本身可能含这些
49+
// 标记(ACECode 自己的源码和测试就有),子串一出现就拦,这类编辑会永远失败。
50+
// 命中条件:
51+
// - 参数不是合法 JSON,且含任一 GLM 标记(这份参数本来就没法用);
52+
// - JSON 对象的键名含任一 GLM 标记(键名是参数名,不会合法地带标签);
53+
// - 字符串值里出现「`</arg_key>` 后(可隔空白)紧跟 `<arg_value>`」或
54+
// 「`</arg_value>` 后紧跟 `<arg_key>` / `<tool_call>` / `</tool_call>`」
55+
// (相邻参数的模板漏进了这个值),或值以 `</arg_value>` 结尾(值自己的
56+
// 闭合标签没剥掉)。
57+
std::optional<ToolCallCorruption> diagnose_corrupted_tool_arguments(std::string_view arguments);
58+
59+
struct CorruptedToolCall {
60+
std::size_t index = 0; // 在 calls 中的下标
61+
ToolCallCorruption corruption;
62+
};
63+
64+
// 逐个检查一步回复里的工具调用,返回第一个损坏的。is_registered 判为已注册的
65+
// 名字不按名字判(参数照查);is_registered 为空时只按名字本身判。
66+
std::optional<CorruptedToolCall> find_corrupted_tool_call(
67+
const std::vector<ToolCall>& calls,
68+
const std::function<bool(const std::string&)>& is_registered);
69+
70+
} // namespace acecode

0 commit comments

Comments
 (0)