diff --git a/CLAUDE.md b/CLAUDE.md
index d94ae65a..df58944a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -152,7 +152,9 @@ generates one standard-quality image and must never run on load or save.
[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.
-**输出损坏重发一次(``)。** 回复正文里(代码块与行内代码之外)出现工具参数模板标记 `` / `` 及闭合标签(`find_leaked_tool_argument_markup`),说明网关把模板残片吐进了输出流,这一步的正文与同一回复里的原生工具调用都不可信:TurnRunner 在执行工具之前整条丢弃(不入历史、不执行)、清掉 TUI / Web 的流式草稿、发 `response_corrupted_retry` 通知后原样重发;每步连续最多 1 次(`ResponseRecoveryState::corrupted_output_retries`,产出有效工具批次后清零),重发后仍损坏就按原流程继续。`` 刻意不算 —— 把推理写进正文的模型会合法输出它(用户拍板:think 残片不处理)。起因:反馈 huangyuan816,坏回复里的 bash 命令也夹着乱码照样执行,之后整个回合答非所问。
+**输出损坏重发一次(``、坏工具调用)。** 两类都说明网关把模板残片吐进了输出流,这一步的正文与同一回复里的全部工具调用都不可信:(1) 回复正文里(代码块与行内代码之外)出现工具参数模板标记 `` / `` 及闭合标签(`find_leaked_tool_argument_markup`);(2) 工具调用本身坏了([tool_call_corruption.hpp](src/domain/llm/tool_call_corruption.hpp)):名字为空,或名字不符合 `^[A-Za-z0-9_-]{1,64}$` 且夹着 GLM 模板标记(`` / `` / `` 及闭合)或调用语法(括号、方括号、引号、等号),已注册的名字不按名字判;参数只认模板残片的**结构**(值以 `` 结尾、`` 后紧跟 ``、`` 后紧跟下一段模板、键名带标记、非 JSON 且含标记)。**不要**把参数检查改成子串一出现就拦:编辑本来就含这些标记的文件(ACECode 自己的源码和测试)会被每次丢弃重发、重发后照样命中而报错停下。命中后:TurnRunner 在执行工具之前整条丢弃(不入历史、不执行)、清掉 TUI / Web 的流式草稿、发 `response_corrupted_retry` 通知后原样重发(工具调用损坏的通知带 `context` = `tool_name_empty` / `tool_name` / `tool_arguments`,Web 按 i18next context 选 `response_corrupted_retry_` 文案);每步连续最多 1 次(`ResponseRecoveryState::corrupted_output_retries`,产出有效工具批次后清零),重发后仍损坏就报错停止。格式正常但不存在的名字(`Bash`、`functions.x`)不算损坏,照旧走 Unknown tool;`ToolErrors::unknown_tool` 对空名字 / 损坏名字不回显原名。`` 刻意不算 —— 把推理写进正文的模型会合法输出它(用户拍板:think 残片不处理)。起因:反馈 huangyuan816,坏回复里的 bash 命令也夹着乱码照样执行,之后整个回合答非所问;内网 wizard-ai 网关 aicoder-pro(GLM 5.2 FP8,2026-10-14)高峰期没能把模型写的工具调用模板转成结构化调用,`skill_view(name="kimi-webbridge")` 这类名字被当成 Unknown tool 执行,坏名字连同回显它的报错写回历史,又被服务端按模板排回提示词,会话 20261014-081557-766b 连续空转 13 轮。
+
+旧会话里已经落盘的坏调用由历史投影兜底(`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`。
`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。
diff --git a/src/adapters/tool/memory_read_tool.cpp b/src/adapters/tool/memory_read_tool.cpp
index 110efe43..b2729945 100644
--- a/src/adapters/tool/memory_read_tool.cpp
+++ b/src/adapters/tool/memory_read_tool.cpp
@@ -1,6 +1,7 @@
#include "memory_read_tool.hpp"
#include "memory_tool_scope.hpp"
+#include "llm/tool_protocol_names.hpp"
#include "memory/memory_service.hpp"
#include "memory/memory_types.hpp"
@@ -165,7 +166,9 @@ ToolImpl create_memory_read_tool(std::shared_ptr memory) {
}
out["found"] = false;
out["name"] = name;
- out["hint"] = "Use memory_read() with no args to list available entries.";
+ // 文字描述调用方式,不写伪函数调用(GLM 系模型会把它照抄成工具名)。
+ out["hint"] = "Call " + model_tool_name_for_native("memory_read") +
+ " without arguments to list available entries.";
LOG_DEBUG("[memory_read] entry not found: " + name);
return ToolResult{out.dump(), true};
}
diff --git a/src/adapters/tool/skills_tool.cpp b/src/adapters/tool/skills_tool.cpp
index 455af212..fdee7927 100644
--- a/src/adapters/tool/skills_tool.cpp
+++ b/src/adapters/tool/skills_tool.cpp
@@ -1,6 +1,7 @@
#include "skills_tool.hpp"
#include "config/config.hpp"
+#include "llm/tool_protocol_names.hpp"
#include "skills/skill_init.hpp"
#include "skills/skill_registry.hpp"
#include "utils/logger.hpp"
@@ -170,7 +171,10 @@ ToolImpl create_skills_list_tool(SkillRegistry& registry,
? "No skills installed. Add SKILL.md files under ~/.acecode/skills/ or ~/.agent/skills/."
: ("No skills in category '" + normalized_category + "'.");
} else {
- out["hint"] = "Use skill_view(name=...) to load the full content of a skill.";
+ // 文字描述调用方式,不写伪函数调用(GLM 系模型会把它照抄成工具名)。
+ out["hint"] = "To load a skill's full content, call the " +
+ model_tool_name_for_native("skill_view") +
+ " tool with its name argument set to the skill name.";
}
LOG_DEBUG("[skills_list] requested_category='" + requested_category +
diff --git a/src/adapters/tool/tool_errors.hpp b/src/adapters/tool/tool_errors.hpp
index 1c67314a..febcf1b5 100644
--- a/src/adapters/tool/tool_errors.hpp
+++ b/src/adapters/tool/tool_errors.hpp
@@ -1,5 +1,6 @@
#pragma once
+#include "llm/tool_call_corruption.hpp"
#include "llm/tool_protocol_names.hpp"
#include
@@ -165,9 +166,21 @@ class ToolErrors {
// 模型侧工具名(AgentLoop 从 bundle.tool_defs 取),列出来让模型照抄;
// 为空(未接线)时回退旧文案。最多列 kMaxListed 个,超出写 (+N more)
// —— 54 个工具的请求实测存在,100 足以全列,再多只会把结果撑成噪音。
+ // 名字为空或已损坏(调用表达式、夹着模板标记,判定见 llm/tool_call_corruption)
+ // 时不回显:报错会写进历史,原样回显等于再给模型看一遍错误示范(内网 GLM
+ // 2026-10-14,`skill_view(name="…")` 隔两轮又被照抄出来)。
static std::string unknown_tool(const std::string& name,
const std::vector& available) {
- std::string msg = "[Error] Unknown tool: " + name;
+ std::string msg;
+ if (const auto corruption = diagnose_corrupted_tool_name(name)) {
+ msg = corruption->reason == ToolCallCorruption::Reason::EmptyName
+ ? "[Error] The tool call had no tool name, so nothing was executed"
+ : "[Error] Unknown tool: the requested tool name was malformed (it "
+ "contained call syntax or template markup instead of a plain tool "
+ "name), so it is not repeated here";
+ } else {
+ msg = "[Error] Unknown tool: " + name;
+ }
if (available.empty()) return msg;
constexpr std::size_t kMaxListed = 100;
msg += ". Available tools: ";
diff --git a/src/adapters/tool/tool_executor.cpp b/src/adapters/tool/tool_executor.cpp
index a022b3ae..4f269940 100644
--- a/src/adapters/tool/tool_executor.cpp
+++ b/src/adapters/tool/tool_executor.cpp
@@ -468,7 +468,7 @@ ToolResult ToolExecutor::execute(const std::string& tool_name, const std::string
auto it = tools_.find(tool_name);
if (it == tools_.end()) {
LOG_ERROR("execute: unknown tool '" + tool_name + "'");
- ToolResult result{"[Error] Unknown tool: " + tool_name, false};
+ ToolResult result{ToolErrors::unknown_tool(tool_name, {}), false};
ensure_tool_summary(tool_name, arguments_json, result);
return result;
}
diff --git a/src/domain/llm/tool_call_corruption.cpp b/src/domain/llm/tool_call_corruption.cpp
new file mode 100644
index 00000000..4311e790
--- /dev/null
+++ b/src/domain/llm/tool_call_corruption.cpp
@@ -0,0 +1,141 @@
+#include "tool_call_corruption.hpp"
+
+#include
+
+namespace acecode {
+
+namespace {
+
+constexpr std::string_view kToolCallOpen = "";
+constexpr std::string_view kToolCallClose = "";
+constexpr std::string_view kArgKeyOpen = "";
+constexpr std::string_view kArgKeyClose = "";
+constexpr std::string_view kArgValueOpen = "";
+constexpr std::string_view kArgValueClose = "";
+constexpr std::string_view kGlmMarkers[] = {
+ kToolCallOpen, kToolCallClose, kArgKeyOpen, kArgKeyClose, kArgValueOpen, kArgValueClose};
+
+// 名字里出现这些字符,说明模型把调用表达式当成了名字:
+// skill_view(name="x")、skill_view["x"]、{"name": …}。
+constexpr std::string_view kCallSyntaxChars = "()[]{}\"'=<>`";
+
+constexpr std::string_view kWhitespace = " \t\r\n";
+
+bool is_blank(std::string_view text) {
+ return text.find_first_not_of(kWhitespace) == std::string_view::npos;
+}
+
+// pos 起跳过空白后是否以 expected 开头。
+bool followed_by(std::string_view text, std::size_t pos, std::string_view expected) {
+ const auto next = text.find_first_not_of(kWhitespace, pos);
+ return next != std::string_view::npos && text.compare(next, expected.size(), expected) == 0;
+}
+
+// 字符串值里的模板结构,命中返回结构开头的标记。规则见头文件。
+std::optional find_template_structure(std::string_view value) {
+ for (auto pos = value.find(kArgKeyClose); pos != std::string_view::npos;
+ pos = value.find(kArgKeyClose, pos + 1)) {
+ if (followed_by(value, pos + kArgKeyClose.size(), kArgValueOpen)) {
+ return std::string(kArgKeyClose);
+ }
+ }
+ for (auto pos = value.find(kArgValueClose); pos != std::string_view::npos;
+ pos = value.find(kArgValueClose, pos + 1)) {
+ const std::size_t after = pos + kArgValueClose.size();
+ if (is_blank(value.substr(after)) || followed_by(value, after, kArgKeyOpen) ||
+ followed_by(value, after, kToolCallOpen) || followed_by(value, after, kToolCallClose)) {
+ return std::string(kArgValueClose);
+ }
+ }
+ return std::nullopt;
+}
+
+std::optional find_corrupted_json_text(const nlohmann::json& value) {
+ if (value.is_string()) return find_template_structure(value.get_ref());
+ if (value.is_object()) {
+ for (auto it = value.begin(); it != value.end(); ++it) {
+ if (auto marker = find_glm_tool_call_marker(it.key())) return marker;
+ if (auto marker = find_corrupted_json_text(it.value())) return marker;
+ }
+ } else if (value.is_array()) {
+ for (const auto& item : value) {
+ if (auto marker = find_corrupted_json_text(item)) return marker;
+ }
+ }
+ return std::nullopt;
+}
+
+} // namespace
+
+bool is_conventional_tool_name(std::string_view name) {
+ if (name.empty() || name.size() > 64) return false;
+ for (const char c : name) {
+ const bool allowed = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
+ (c >= '0' && c <= '9') || c == '_' || c == '-';
+ if (!allowed) return false;
+ }
+ return true;
+}
+
+std::optional find_glm_tool_call_marker(std::string_view text) {
+ std::size_t earliest = std::string_view::npos;
+ std::string_view found;
+ for (const auto marker : kGlmMarkers) {
+ const auto pos = text.find(marker);
+ if (pos < earliest) {
+ earliest = pos;
+ found = marker;
+ }
+ }
+ if (earliest == std::string_view::npos) return std::nullopt;
+ return std::string(found);
+}
+
+std::optional diagnose_corrupted_tool_name(std::string_view name) {
+ using Field = ToolCallCorruption::Field;
+ using Reason = ToolCallCorruption::Reason;
+ if (is_blank(name)) return ToolCallCorruption{Field::Name, Reason::EmptyName, {}};
+ if (is_conventional_tool_name(name)) return std::nullopt;
+ if (auto marker = find_glm_tool_call_marker(name)) {
+ return ToolCallCorruption{Field::Name, Reason::TemplateMarkup, std::move(*marker)};
+ }
+ const auto syntax = name.find_first_of(kCallSyntaxChars);
+ if (syntax != std::string_view::npos) {
+ return ToolCallCorruption{Field::Name, Reason::CallSyntax, std::string(1, name[syntax])};
+ }
+ return std::nullopt;
+}
+
+std::optional diagnose_corrupted_tool_arguments(std::string_view arguments) {
+ // 快路径:绝大多数参数里连标签名都没有(file_write 的参数可能有几百 KB)。
+ if (arguments.find("arg_") == std::string_view::npos &&
+ arguments.find("tool_call>") == std::string_view::npos) {
+ return std::nullopt;
+ }
+ const auto parsed = nlohmann::json::parse(arguments, nullptr, false);
+ std::optional marker = parsed.is_discarded()
+ ? find_glm_tool_call_marker(arguments)
+ : find_corrupted_json_text(parsed);
+ if (!marker) return std::nullopt;
+ return ToolCallCorruption{ToolCallCorruption::Field::Arguments,
+ ToolCallCorruption::Reason::TemplateMarkup, std::move(*marker)};
+}
+
+std::optional find_corrupted_tool_call(
+ const std::vector& calls,
+ const std::function& is_registered) {
+ for (std::size_t i = 0; i < calls.size(); ++i) {
+ const auto& call = calls[i];
+ if (!is_registered || !is_registered(call.function_name)) {
+ if (auto corruption = diagnose_corrupted_tool_name(call.function_name)) {
+ return CorruptedToolCall{i, std::move(*corruption)};
+ }
+ }
+ if (auto corruption = diagnose_corrupted_tool_arguments(call.function_arguments)) {
+ return CorruptedToolCall{i, std::move(*corruption)};
+ }
+ }
+ return std::nullopt;
+}
+
+} // namespace acecode
diff --git a/src/domain/llm/tool_call_corruption.hpp b/src/domain/llm/tool_call_corruption.hpp
new file mode 100644
index 00000000..f723c070
--- /dev/null
+++ b/src/domain/llm/tool_call_corruption.hpp
@@ -0,0 +1,70 @@
+#pragma once
+
+// 损坏工具调用判定。纯字符串 / JSON 逻辑,无 IO:TurnRunner 据此整步丢弃重发,
+// ToolErrors 据此决定 Unknown tool 报错回不回显名字,发给模型的历史投影据此
+// 藏起旧会话里的坏调用 —— 三处同一口径。
+//
+// 背景(内网 wizard-ai 网关 aicoder-pro = GLM 5.2 FP8,2026-10-14 daemon 日志):
+// GLM 把工具调用写进聊天模板(`名字` 加成对的 `` /
+// ``),由服务端解析成结构化 tool_calls。高峰期解析失败,返回的函数名
+// 变成 `skill_view(name="kimi-webbridge")`、几段调用串在同一个名字
+// 里,或者干脆是空名字 + `{}`。这类调用执行了也只会报 Unknown tool,坏名字还会
+// 写回历史,下一轮被服务端按模板排回提示词,等于给模型看错误示范。
+
+#include "llm/llm_provider.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace acecode {
+
+// 工具名字符集 ^[A-Za-z0-9_-]{1,64}$(与「工具重写」模型侧名同一规则,也是
+// OpenAI / Anthropic 对 function name 的约束)。
+bool is_conventional_tool_name(std::string_view name);
+
+// 文本里最早出现的 GLM 工具调用模板标记(
+// ),没有返回空。
+std::optional find_glm_tool_call_marker(std::string_view text);
+
+struct ToolCallCorruption {
+ enum class Field { Name, Arguments };
+ enum class Reason { EmptyName, TemplateMarkup, CallSyntax };
+ Field field = Field::Name;
+ Reason reason = Reason::EmptyName;
+ // 命中的模板标记或调用语法字符;空名字时为空。
+ std::string marker;
+};
+
+// 工具名损坏:名字为空(或只有空白);或不符合工具名字符集,且含 GLM 模板标记
+// 或调用语法(圆括号、方括号、花括号、引号、等号、尖括号、反引号)。格式正常但
+// 不存在的名字(`Bash`、`functions.bash`)不算,那走 Unknown tool,报错会列出
+// 可用工具。已注册的工具名由调用方先排除。
+std::optional diagnose_corrupted_tool_name(std::string_view name);
+
+// 参数损坏:只认模板残片的结构,不认单个标记。模型要编辑的文件本身可能含这些
+// 标记(ACECode 自己的源码和测试就有),子串一出现就拦,这类编辑会永远失败。
+// 命中条件:
+// - 参数不是合法 JSON,且含任一 GLM 标记(这份参数本来就没法用);
+// - JSON 对象的键名含任一 GLM 标记(键名是参数名,不会合法地带标签);
+// - 字符串值里出现「`` 后(可隔空白)紧跟 ``」或
+// 「`` 后紧跟 `` / `` / ``」
+// (相邻参数的模板漏进了这个值),或值以 `` 结尾(值自己的
+// 闭合标签没剥掉)。
+std::optional diagnose_corrupted_tool_arguments(std::string_view arguments);
+
+struct CorruptedToolCall {
+ std::size_t index = 0; // 在 calls 中的下标
+ ToolCallCorruption corruption;
+};
+
+// 逐个检查一步回复里的工具调用,返回第一个损坏的。is_registered 判为已注册的
+// 名字不按名字判(参数照查);is_registered 为空时只按名字本身判。
+std::optional find_corrupted_tool_call(
+ const std::vector& calls,
+ const std::function& is_registered);
+
+} // namespace acecode
diff --git a/src/domain/session/session_history_recovery.cpp b/src/domain/session/session_history_recovery.cpp
index d641e427..e4456375 100644
--- a/src/domain/session/session_history_recovery.cpp
+++ b/src/domain/session/session_history_recovery.cpp
@@ -1,5 +1,7 @@
#include "session_history_recovery.hpp"
+#include "llm/tool_call_corruption.hpp"
+
#include
#include
#include
@@ -23,27 +25,56 @@ bool has_assistant_content(const ChatMessage& msg) {
(msg.content_parts.is_array() && !msg.content_parts.empty());
}
-std::optional valid_tool_call_id(const nlohmann::json& call) {
+std::optional tool_call_id(const nlohmann::json& call) {
if (!call.is_object() ||
!call.contains("id") ||
!call["id"].is_string()) {
return std::nullopt;
}
- const std::string id = call["id"].get();
- if (id.empty() ||
+ std::string id = call["id"].get();
+ if (id.empty()) return std::nullopt;
+ return id;
+}
+
+std::optional valid_tool_call_id(const nlohmann::json& call) {
+ auto id = tool_call_id(call);
+ if (!id ||
!call.contains("function") ||
!call["function"].is_object()) {
return std::nullopt;
}
const auto& function = call["function"];
+ // 空名字与损坏的名字(调用表达式、夹着模板标记)都不发给模型:原样排回
+ // 提示词等于给模型看错误示范。
if (!function.contains("name") ||
!function["name"].is_string() ||
- function["name"].get().empty()) {
+ diagnose_corrupted_tool_name(
+ function["name"].get_ref())) {
return std::nullopt;
}
return id;
}
+ChatMessage malformed_tool_call_note() {
+ ChatMessage note;
+ note.role = "user";
+ note.content = kMalformedToolCallNote;
+ // hidden_goal_context:内部上下文,不算真实用户回合的起点,界面也不显示。
+ note.metadata = nlohmann::json{
+ {"hidden_goal_context", true},
+ {"session_recovery", {
+ {"version", 1},
+ {"kind", "malformed_tool_call"},
+ {"outcome", "not_executed"},
+ }},
+ };
+ return note;
+}
+
+bool is_malformed_tool_call_note(const ChatMessage& msg) {
+ return msg.role == "user" && msg.content == kMalformedToolCallNote;
+}
+
ChatMessage interrupted_tool_result(const std::string& tool_call_id) {
ChatMessage result;
result.role = "tool";
@@ -90,6 +121,11 @@ ProviderHistoryRecoveryResult recover_provider_history(
std::unordered_map pending_ids;
std::unordered_set seen_call_ids;
std::unordered_set seen_result_ids;
+ // 本组里被丢掉的坏调用(空名字 / 损坏的名字)的 ID。它们的报错结果不能作为
+ // tool 消息发出(没有对应的调用),也不能直接丢:内网 GLM 会话里空名调用连同
+ // 报错一起被删,请求以孤立的 assistant 消息结尾,模型看不到失败,原样重复。
+ std::unordered_set malformed_ids;
+ bool malformed_note_pending = false;
const auto flush_missing_results = [&]() {
for (const auto& id : pending_order) {
@@ -101,6 +137,15 @@ ProviderHistoryRecoveryResult recover_provider_history(
}
pending_order.clear();
pending_ids.clear();
+ // 说明放在本组全部工具结果之后,不拆开 assistant(tool_calls) 与结果的配对。
+ // 相邻的同一说明只留一条:旧会话里连续十几轮坏调用不必重复十几遍。
+ if (malformed_note_pending &&
+ (result.messages.empty() ||
+ !is_malformed_tool_call_note(result.messages.back()))) {
+ result.messages.push_back(malformed_tool_call_note());
+ }
+ malformed_note_pending = false;
+ malformed_ids.clear();
};
for (const auto& original : messages) {
@@ -126,6 +171,9 @@ ProviderHistoryRecoveryResult recover_provider_history(
const auto id = valid_tool_call_id(call);
if (!id.has_value()) {
++result.stats.malformed_tool_calls;
+ if (auto bad_id = tool_call_id(call)) {
+ malformed_ids.insert(std::move(*bad_id));
+ }
continue;
}
if (!batch_ids.insert(*id).second) {
@@ -172,6 +220,11 @@ ProviderHistoryRecoveryResult recover_provider_history(
seen_result_ids.insert(id);
continue;
}
+ if (malformed_ids.erase(id) > 0) {
+ ++result.stats.malformed_call_results;
+ malformed_note_pending = true;
+ continue;
+ }
if (!id.empty() && seen_result_ids.count(id)) {
++result.stats.duplicate_tool_results;
} else if (pending_ids.empty()) {
diff --git a/src/domain/session/session_history_recovery.hpp b/src/domain/session/session_history_recovery.hpp
index 9f78a0bd..31a3c0c6 100644
--- a/src/domain/session/session_history_recovery.hpp
+++ b/src/domain/session/session_history_recovery.hpp
@@ -18,6 +18,8 @@ struct ProviderHistoryRecoveryStats {
std::size_t unexpected_tool_results = 0;
std::size_t duplicate_tool_results = 0;
std::size_t empty_assistant_messages = 0;
+ // Results of dropped malformed calls, replaced by a "not executed" note.
+ std::size_t malformed_call_results = 0;
bool changed() const {
return malformed_tool_calls > 0 ||
@@ -26,7 +28,8 @@ struct ProviderHistoryRecoveryStats {
standalone_tool_results > 0 ||
unexpected_tool_results > 0 ||
duplicate_tool_results > 0 ||
- empty_assistant_messages > 0;
+ empty_assistant_messages > 0 ||
+ malformed_call_results > 0;
}
};
@@ -35,9 +38,22 @@ struct ProviderHistoryRecoveryResult {
ProviderHistoryRecoveryStats stats;
};
+// 调用被当成损坏丢掉、但它的报错结果还在历史里时,投影在这一组工具结果之后
+// 放一条 user 消息说明「调用没有执行」。只由内容决定、逐字节稳定。
+inline constexpr const char* kMalformedToolCallNote =
+ "[Tool call not executed] A tool call in the previous assistant message had an "
+ "empty or malformed tool name (not a plain name from the tool list), so nothing "
+ "was executed. Call tools through the tool-calling interface, using an exact tool "
+ "name from the tool list and JSON arguments.";
+
// Repair the logical provider projection without rewriting the append-only
// human transcript. Missing tool results become explicit outcome-unknown
// placeholders; invalid result rows are omitted from this projection only.
+// Calls with an empty or corrupted name (llm/tool_call_corruption) are
+// dropped; if such a call's error result was persisted, it becomes one
+// kMalformedToolCallNote user message after the group's results, so the
+// request does not end on an orphaned assistant message and the model still
+// sees that its call failed — without the corrupted name being echoed back.
ProviderHistoryRecoveryResult recover_provider_history(
const std::vector& messages);
diff --git a/src/domain/session/thread_repair.cpp b/src/domain/session/thread_repair.cpp
index 6d59d402..6391dcca 100644
--- a/src/domain/session/thread_repair.cpp
+++ b/src/domain/session/thread_repair.cpp
@@ -347,6 +347,7 @@ nlohmann::json thread_repair_result_to_json(
{"unexpectedToolResults", result.history_issues.unexpected_tool_results},
{"duplicateToolResults", result.history_issues.duplicate_tool_results},
{"emptyAssistantMessages", result.history_issues.empty_assistant_messages},
+ {"malformedCallResults", result.history_issues.malformed_call_results},
{"malformedRecords", result.load_issues.malformed_complete_records},
{"ignoredPartialTail", result.load_issues.ignored_partial_tail},
{"recoveredUnterminatedRecord",
diff --git a/src/engine/agent/request/provider_history.cpp b/src/engine/agent/request/provider_history.cpp
index c10802ce..4b7260a6 100644
--- a/src/engine/agent/request/provider_history.cpp
+++ b/src/engine/agent/request/provider_history.cpp
@@ -32,7 +32,9 @@ std::vector recovered_provider_messages(
" duplicate_results=" +
std::to_string(stats.duplicate_tool_results) +
" empty_assistants=" +
- std::to_string(stats.empty_assistant_messages));
+ std::to_string(stats.empty_assistant_messages) +
+ " malformed_results=" +
+ std::to_string(stats.malformed_call_results));
}
return std::move(recovery.messages);
}
diff --git a/src/engine/agent/turn/turn_runner.cpp b/src/engine/agent/turn/turn_runner.cpp
index 3c8414ef..5ae36a0e 100644
--- a/src/engine/agent/turn/turn_runner.cpp
+++ b/src/engine/agent/turn/turn_runner.cpp
@@ -20,6 +20,7 @@
#include "agent/turn/turn_outcome.hpp"
#include "agent/compaction/compact.hpp"
#include "agent/transcript/transcript_queries.hpp"
+#include "llm/tool_call_corruption.hpp"
#include "provider/text_tool_call_recovery.hpp"
#include "session/event_dispatcher.hpp"
#include "session/inter_agent_message.hpp"
@@ -37,12 +38,54 @@ namespace {
// 输出损坏只重发一次:网关偶发把模板残片吐进输出流,重采通常就好;连续两次
// 损坏多半是服务端持续异常,停止并报错,绝不放行损坏回复中的工具调用。
constexpr int kMaxCorruptedOutputRetries = 1;
+
+// 工具调用损坏的中文说明,重发通知与最终报错共用。
+std::string describe_tool_call_corruption(const ToolCallCorruption& corruption) {
+ if (corruption.reason == ToolCallCorruption::Reason::EmptyName) return u8"工具名为空";
+ const std::string field =
+ corruption.field == ToolCallCorruption::Field::Name ? u8"工具名" : u8"工具参数";
+ return field + (corruption.reason == ToolCallCorruption::Reason::CallSyntax
+ ? u8"混入了调用语法 " : u8"混入了模板标记 ") + corruption.marker;
+}
+
+std::string tool_call_notice_context(const ToolCallCorruption& corruption) {
+ if (corruption.reason == ToolCallCorruption::Reason::EmptyName) return "tool_name_empty";
+ return corruption.field == ToolCallCorruption::Field::Name ? "tool_name" : "tool_arguments";
+}
} // namespace
+std::optional TurnRunner::find_corrupted_output(
+ const ChatResponse& response) const {
+ if (const auto marker = find_leaked_tool_argument_markup(response.content)) {
+ return CorruptedOutput{*marker, {}, {},
+ "leaked template markup " + *marker + " in reply text"};
+ }
+ // 已注册的名字一律不按名字判(MCP 工具名不受字符集约束),参数照查。
+ const auto found = find_corrupted_tool_call(response.tool_calls,
+ [&tools = tools_](const std::string& name) { return tools.has_tool(name); });
+ if (!found) return std::nullopt;
+ const auto& call = response.tool_calls[found->index];
+ const auto& corruption = found->corruption;
+ std::string log_detail = "tool call #" + std::to_string(found->index) + " name='" +
+ log_truncate(call.function_name, 200) + "' ";
+ if (corruption.reason == ToolCallCorruption::Reason::EmptyName) {
+ log_detail += "has an empty tool name";
+ } else {
+ log_detail += corruption.field == ToolCallCorruption::Field::Name
+ ? "is malformed (" : "has malformed arguments (";
+ log_detail += corruption.reason == ToolCallCorruption::Reason::CallSyntax
+ ? "call syntax " : "template markup ";
+ log_detail += corruption.marker + ")";
+ }
+ log_detail += " arguments=" + log_truncate(call.function_arguments, 200);
+ return CorruptedOutput{corruption.marker, tool_call_notice_context(corruption),
+ describe_tool_call_corruption(corruption), std::move(log_detail)};
+}
+
void TurnRunner::discard_corrupted_output(const ChatResponse& response,
- const std::string& marker, int attempt, int attempts, bool retrying) {
- LOG_WARN("Corrupted model output: leaked template markup " + marker +
- " in reply text; discarding the step without running its " +
+ const CorruptedOutput& corrupted, int attempt, int attempts, bool retrying) {
+ LOG_WARN("Corrupted model output: " + corrupted.log_detail +
+ "; discarding the step without running its " +
std::to_string(response.tool_calls.size()) +
" tool call(s); retrying=" + (retrying ? "true" : "false") +
" attempt=" + std::to_string(attempt) + "/" +
@@ -56,19 +99,30 @@ void TurnRunner::discard_corrupted_output(const ChatResponse& response,
session_ ? session_->load_active_messages() : history_.view();
events_.emit(SessionEventKind::TranscriptReplace,
detail::build_transcript_replace_payload(visible, CompactResult{}));
+ const bool tool_call = !corrupted.summary.empty();
if (!retrying) {
transcript_.dispatch_message("error",
- "[输出异常] 模型重试后仍返回损坏的工具模板,已丢弃回复并停止,"
- "未执行其中的工具调用。请重试或切换模型。",
+ tool_call
+ ? "[输出异常] 模型重试后仍返回损坏的工具调用(" + corrupted.summary +
+ "),已丢弃回复并停止,未执行其中的工具调用。请重试或切换模型。"
+ : std::string("[输出异常] 模型重试后仍返回损坏的工具模板,已丢弃回复并停止,"
+ "未执行其中的工具调用。请重试或切换模型。"),
false, nlohmann::json::object(), nlohmann::json::array());
return;
}
+ nlohmann::json params = {
+ {"attempt", attempt}, {"attempts", attempts}, {"marker", corrupted.marker}};
+ // context 是 i18next 的上下文键:Web 据此改用 response_corrupted_retry_
+ // 文案(工具名为空 / 工具名损坏 / 工具参数损坏),不认识的旧客户端回退到基础文案。
+ if (!corrupted.notice_context.empty()) params["context"] = corrupted.notice_context;
+ const std::string what = tool_call
+ ? std::string(u8"模型返回的工具调用已损坏(") + corrupted.summary
+ : std::string(u8"模型回复里混入了工具参数模板标记(") + corrupted.marker;
transcript_.emit_transcript_system_message(session_,
- std::string(u8"[输出异常] 模型回复里混入了工具参数模板标记(") + marker +
+ std::string(u8"[输出异常] ") + what +
u8"),正文与工具调用都不可信,已丢弃并重新请求 " +
std::to_string(attempt) + "/" + std::to_string(attempts) + u8"…",
- make_system_notice_metadata("response_corrupted_retry",
- {{"attempt", attempt}, {"attempts", attempts}, {"marker", marker}}));
+ make_system_notice_metadata("response_corrupted_retry", std::move(params)));
}
void TurnRunner::run(TurnContext& turn, const UserInput& input, bool hidden_goal_context,
@@ -285,20 +339,24 @@ void TurnRunner::run(TurnContext& turn, const UserInput& input, bool hidden_goal
break;
}
- // Phase 4b: 输出损坏 —— 正文里混进了工具参数模板标记( 等)。
- // 这一步的正文和工具调用都不可信:整条丢弃(不入历史、不执行工具)、
- // 原样重发一次;重发后仍损坏就停止。反馈 huangyuan816:第一条
- // 回复是一串数字加 ,同一回复里的 bash 命令也夹着乱码,照样
- // 被执行,之后整个回合跑题。检查放在 has_tool_calls 分支之前,带原生
- // 工具调用的回复同样拦下。
- if (const auto marker = find_leaked_tool_argument_markup(
- provider_result.accumulated.content)) {
+ // Phase 4b: 输出损坏 —— 正文里混进了工具参数模板标记( 等),
+ // 或者工具调用本身坏了(名字为空、名字是调用表达式或夹着模板标记、参数
+ // 里漏进了相邻参数的模板)。这一步的正文和工具调用都不可信:整条丢弃
+ // (不入历史、不执行工具)、原样重发一次;重发后仍损坏就停止。反馈
+ // huangyuan816:第一条回复是一串数字加 ,同一回复里的 bash
+ // 命令也夹着乱码,照样被执行,之后整个回合跑题。内网 GLM(2026-10-14):
+ // 网关没能把模型写的工具调用模板转成结构化调用,`skill_view(name="…")
+ // ` 这类名字被当成 Unknown tool 执行、写回历史,下一轮又被
+ // 排回提示词,会话 20261014-081557-766b 连续空转 13 轮。检查放在
+ // has_tool_calls 分支之前,带原生工具调用的回复同样拦下;格式正常但不存在
+ // 的工具名不算损坏,照旧走 Unknown tool。
+ if (const auto corrupted = find_corrupted_output(provider_result.accumulated)) {
const bool retrying = turn.response_recovery.corrupted_output_retries <
kMaxCorruptedOutputRetries;
if (retrying) {
++turn.response_recovery.corrupted_output_retries;
}
- discard_corrupted_output(provider_result.accumulated, *marker,
+ discard_corrupted_output(provider_result.accumulated, *corrupted,
turn.response_recovery.corrupted_output_retries,
kMaxCorruptedOutputRetries, retrying);
const std::string status = retrying ? "retry" : "error";
diff --git a/src/engine/agent/turn/turn_runner.hpp b/src/engine/agent/turn/turn_runner.hpp
index 11f312ec..190b724e 100644
--- a/src/engine/agent/turn/turn_runner.hpp
+++ b/src/engine/agent/turn/turn_runner.hpp
@@ -4,6 +4,9 @@
#include "agent/tool_exec/tool_batch_scheduler.hpp"
#include "turn_finalizer.hpp"
+#include
+#include
+
namespace acecode::agent {
class CompactionController;
struct CompactionInputs;
@@ -51,9 +54,17 @@ class TurnRunner {
CompactionInputs compaction_inputs(const TurnContext& turn,
LifetimeRef terminal) const;
bool drain_inputs(bool close_if_empty);
+ // 一步输出损坏的命中信息:正文里漏出的模板标记,或某个工具调用的名字 / 参数。
+ struct CorruptedOutput {
+ std::string marker; // 命中的模板标记或调用语法字符,空名字时为空
+ std::string notice_context; // 空 = 正文;否则是通知文案的 i18next context
+ std::string summary; // 工具调用损坏的中文说明(通知用),正文损坏为空
+ std::string log_detail; // 日志里定位用的英文说明
+ };
+ std::optional find_corrupted_output(const ChatResponse& response) const;
// 丢弃一步损坏的输出(不入历史、不执行工具),清掉界面上的流式草稿并提示。
void discard_corrupted_output(const ChatResponse& response,
- const std::string& marker, int attempt, int attempts, bool retrying);
+ const CorruptedOutput& corrupted, int attempt, int attempts, bool retrying);
ToolBatchOutcome execute_tools(TurnContext& turn, const ChatResponse& response,
const std::shared_ptr& provider, const ProgressEmitter& progress);
diff --git a/src/engine/prompt/memory_prompt.cpp b/src/engine/prompt/memory_prompt.cpp
index 2ec93e70..4847b2fb 100644
--- a/src/engine/prompt/memory_prompt.cpp
+++ b/src/engine/prompt/memory_prompt.cpp
@@ -99,8 +99,9 @@ PromptContextBlock build_memory_snapshot_prompt(const MemorySnapshotSource& sour
<< "Persistent memory saved in earlier sessions. Memories are records of the past, "
<< "not live state: before relying on a file path, command or repository fact from a "
<< "memory, verify it with tools. When a memory conflicts with the user's current "
- << "instructions, follow the current instructions. Load an entry's full body with "
- << "memory_read(name=...); memory_read(query=...) also searches entries omitted below.\n\n";
+ << "instructions, follow the current instructions. To load an entry's full body, "
+ << "call the memory_read tool with its name argument set to the entry name; "
+ << "calling it with a query argument instead also searches entries omitted below.\n\n";
if (!global.empty()) oss << global << "\n";
if (!workspace.empty()) oss << workspace << "\n";
block.content = oss.str();
diff --git a/src/engine/prompt/skills_index_context.cpp b/src/engine/prompt/skills_index_context.cpp
index fa8c7042..2964496d 100644
--- a/src/engine/prompt/skills_index_context.cpp
+++ b/src/engine/prompt/skills_index_context.cpp
@@ -95,8 +95,9 @@ PromptContextBlock build_skills_index_context_prompt(
<< "- How to use a skill (progressive disclosure):\n";
if (skill_view_available) {
oss << " 1) After deciding to use a skill that was not already explicitly "
- << "injected, call `skill_view(name=\"...\")` and read its full "
- << "`SKILL.md` completely before taking task actions.\n"
+ << "injected, call the `skill_view` tool with its `name` argument set "
+ << "to the skill name and read the full `SKILL.md` completely before "
+ << "taking task actions.\n"
<< " 2) When `SKILL.md` references another file, load it with "
<< "`skill_view` using the same skill name and referenced relative path.\n";
} else {
diff --git a/src/engine/prompt/system_prompt.cpp b/src/engine/prompt/system_prompt.cpp
index 3d77dad0..847e06e5 100644
--- a/src/engine/prompt/system_prompt.cpp
+++ b/src/engine/prompt/system_prompt.cpp
@@ -591,9 +591,11 @@ std::string build_system_prompt(const ToolExecutor& tools, const std::string& cw
<< "Explicit $SkillName, linked Skill, and slash selections are expanded "
<< "into a complete instruction fragment for that turn.\n\n";
if (skill_view_allowed) {
+ // 用文字描述调用方式,不写 `skill_view(name="…")` 这类伪函数调用:
+ // GLM 系模型会把整段表达式照抄成工具名(内网 2026-10-14)。
oss << "How to invoke:\n"
- << "- Call `skill_view(name=\"\")` to load the full SKILL.md body before acting on a matching task.\n"
- << "- Use `skill_view(name=\"\", file_path=\"\")` to load supporting files (references/, templates/, scripts/, assets/) listed in the skill body.\n";
+ << "- Call the `skill_view` tool with its `name` argument set to the skill name to load the full SKILL.md body before acting on a matching task.\n"
+ << "- To load supporting files (references/, templates/, scripts/, assets/) listed in the skill body, call `skill_view` with the same `name` and set its `file_path` argument to the file's relative path.\n";
}
if (skills_list_allowed) {
oss << "- `skills_list` re-enumerates the full set; use it when the index was truncated or you need to double-check.\n";
diff --git a/tests/agent/agent_loop_termination_test.cpp b/tests/agent/agent_loop_termination_test.cpp
index 34fa474a..c690024c 100644
--- a/tests/agent/agent_loop_termination_test.cpp
+++ b/tests/agent/agent_loop_termination_test.cpp
@@ -2403,6 +2403,218 @@ TEST(AgentLoopTermination, CorruptedOutputAllowanceResetsAfterValidToolStep) {
EXPECT_TRUE(assistant_said(h.persisted_messages(), "done"));
}
+namespace {
+
+// 2026-10-14 内网 wizard-ai 网关(aicoder-pro = GLM 5.2 FP8)daemon 日志里的真实
+// 坏函数名:服务端没能把模型写的 GLM 工具调用模板转成结构化调用。
+const char* const kGlmBrokenName = "skill_view(name=\"kimi-webbridge\")";
+const char* const kGlmChainedName =
+ "skill_view(name=\"SDD-design-analysis\")skill_view(name=\"kimi-webbridge\")"
+ "skill_view(name=\"SDD-requirement-analysis\")";
+
+acecode::ToolCall named_call(const std::string& name, const std::string& id,
+ const std::string& args = "{}") {
+ acecode::ToolCall call;
+ call.id = id;
+ call.function_name = name;
+ call.function_arguments = args;
+ return call;
+}
+
+// 落盘历史里不能出现的东西:坏名字、模板标记、坏调用的 id。系统通知与 error 行
+// 只给人看(不是 provider 角色,不发给模型),会复述命中的标记,跳过。
+void expect_history_free_of(const std::vector& persisted,
+ const std::string& bad_id) {
+ for (const auto& msg : persisted) {
+ if (msg.role == "system" || msg.role == "error") continue;
+ EXPECT_EQ(msg.content.find(""), std::string::npos) << msg.content;
+ EXPECT_EQ(msg.content.find("skill_view(name="), std::string::npos) << msg.content;
+ EXPECT_NE(msg.tool_call_id, bad_id);
+ if (msg.tool_calls.is_array()) {
+ for (const auto& call : msg.tool_calls) {
+ EXPECT_NE(call.value("id", std::string{}), bad_id);
+ }
+ }
+ }
+}
+
+} // namespace
+
+// 场景:GLM 网关返回的函数名是 `skill_view(name="kimi-webbridge")`
+// (日志原样),同一回复没有正文;重发后模型正常回答。
+// 期望:与正文损坏走同一条路 —— 整步丢弃(不执行、不入历史,两次请求逐条相同),
+// 发一条 response_corrupted_retry 通知(context=tool_name、marker=),
+// 最终回复正常落盘,没有错误。
+// 回归表现:坏名字被当成 Unknown tool 执行,连同 `[Error] Unknown tool: <坏名字>`
+// 写进历史,下一轮被服务端按 GLM 模板排回提示词,模型照抄,会话
+// 20261014-081557-766b 连续空转 13 轮。
+TEST(AgentLoopTermination, CorruptedToolNameIsDiscardedAndRequestedAgainOnce) {
+ AgentLoopHarness h;
+ h.push_tool_call(kGlmBrokenName, "{}", "bad-1");
+ h.push_text("recovered answer");
+
+ ASSERT_TRUE(h.submit_and_wait("load the webbridge skill"));
+ EXPECT_EQ(h.turn_count(), 2);
+ EXPECT_EQ(h.count_by_role("error"), 0);
+
+ const auto first = h.request_messages_for_turn(0);
+ const auto second = h.request_messages_for_turn(1);
+ ASSERT_EQ(first.size(), second.size()) << "丢弃后原样重发,历史不能多出消息";
+ for (std::size_t i = 0; i < first.size(); ++i) {
+ EXPECT_EQ(first[i].content, second[i].content) << "message " << i;
+ }
+
+ const auto persisted = h.persisted_messages();
+ expect_history_free_of(persisted, "bad-1");
+ EXPECT_TRUE(assistant_said(persisted, "recovered answer"));
+
+ const auto notices = notice_params_for(h.snapshot_events(), "response_corrupted_retry");
+ ASSERT_EQ(notices.size(), 1u);
+ EXPECT_EQ(notices[0].value("context", std::string{}), "tool_name");
+ EXPECT_EQ(notices[0].value("marker", std::string{}), "");
+ EXPECT_EQ(notices[0].value("attempt", 0), 1);
+}
+
+// 场景:同一回复里第一个调用正常(已注册的计数工具),第二个是几段 ``
+// 串在一起的坏名字(日志原样)。
+// 期望:整步丢弃,连正常的那个调用也不执行(同一回复的输出已不可信);
+// 通知的 marker 是 。
+TEST(AgentLoopTermination, ChainedGlmCallNameDiscardsTheWholeStep) {
+ AgentLoopHarness h;
+ auto runs = std::make_shared>(0);
+ h.register_tool(create_counting_tool("probe", runs));
+ h.push_tool_calls({probe_call("ok-1"), named_call(kGlmChainedName, "bad-2")});
+ h.push_text("done");
+
+ ASSERT_TRUE(h.submit_and_wait("go"));
+ EXPECT_EQ(h.turn_count(), 2);
+ EXPECT_EQ(runs->load(), 0) << "损坏那一步里的正常调用也不能执行";
+ EXPECT_EQ(h.count_by_role("error"), 0);
+ expect_history_free_of(h.persisted_messages(), "bad-2");
+
+ const auto notices = notice_params_for(h.snapshot_events(), "response_corrupted_retry");
+ ASSERT_EQ(notices.size(), 1u);
+ EXPECT_EQ(notices[0].value("marker", std::string{}), "");
+}
+
+// 场景:网关返回函数名为空、参数 {} 的调用(日志:`synthesized missing tool_call
+// id index=0 tool=''`,finish_reason=stop);重发后正常回答。
+// 期望:同样整步丢弃重发一次,通知 context=tool_name_empty、marker 为空;历史里
+// 没有空名调用,也没有它的 Unknown tool 报错。
+// 回归表现:空名调用被执行成 `[Error] Unknown tool: .`,下一轮又被历史修复连同
+// 报错一起删掉,模型看不到失败,原样重复。
+TEST(AgentLoopTermination, EmptyToolNameIsDiscardedAndRequestedAgainOnce) {
+ AgentLoopHarness h;
+ h.push_tool_call("", "{}", "bad-empty");
+ h.push_text("recovered answer");
+
+ ASSERT_TRUE(h.submit_and_wait("go"));
+ EXPECT_EQ(h.turn_count(), 2);
+ EXPECT_EQ(h.count_by_role("error"), 0);
+ const auto persisted = h.persisted_messages();
+ expect_history_free_of(persisted, "bad-empty");
+ for (const auto& msg : persisted) {
+ EXPECT_EQ(msg.content.find("Unknown tool"), std::string::npos) << msg.content;
+ }
+ EXPECT_TRUE(assistant_said(persisted, "recovered answer"));
+
+ const auto notices = notice_params_for(h.snapshot_events(), "response_corrupted_retry");
+ ASSERT_EQ(notices.size(), 1u);
+ EXPECT_EQ(notices[0].value("context", std::string{}), "tool_name_empty");
+ EXPECT_EQ(notices[0].value("marker", std::string{"x"}), "");
+}
+
+// 场景:重发之后网关仍返回坏调用(高峰期服务端持续异常)。
+// 期望:与正文损坏一致,只重发一次;第二次仍损坏就报错停止(error 行说明
+// 「工具调用」损坏),不执行、不写历史,Done 的 outcome 为 error。
+TEST(AgentLoopTermination, RepeatedCorruptedToolCallStopsWithoutExecuting) {
+ AgentLoopHarness h;
+ auto runs = std::make_shared>(0);
+ h.register_tool(create_counting_tool("probe", runs));
+ h.push_tool_call("", "{}", "bad-first");
+ h.push_tool_calls({probe_call("ok-retry"), named_call(kGlmBrokenName, "bad-retry")});
+
+ ASSERT_TRUE(h.submit_and_wait("go"));
+ EXPECT_EQ(h.turn_count(), 2);
+ EXPECT_EQ(runs->load(), 0);
+ EXPECT_EQ(h.count_by_role("error"), 1);
+ bool saw_tool_call_error = false;
+ for (const auto& msg : h.snapshot_messages()) {
+ if (msg.role == "error" &&
+ msg.content.find("损坏的工具调用") != std::string::npos) {
+ saw_tool_call_error = true;
+ }
+ }
+ EXPECT_TRUE(saw_tool_call_error);
+ EXPECT_EQ(notice_params_for(h.snapshot_events(), "response_corrupted_retry").size(), 1u);
+ expect_history_free_of(h.persisted_messages(), "bad-retry");
+ for (const auto& event : h.snapshot_events()) {
+ if (event.kind == acecode::SessionEventKind::Done) {
+ EXPECT_EQ(event.payload.value("outcome", std::string{}), "error");
+ }
+ }
+}
+
+// 场景:工具名正常(已注册),但参数里漏进了相邻参数的模板(上一个值缺闭合标签时
+// 服务端正则的典型结果:`ls -la\ntimeout\n30`)。
+// 期望:同样整步丢弃重发一次,工具不执行,通知 context=tool_arguments、
+// marker=。
+TEST(AgentLoopTermination, CorruptedToolArgumentsAreDiscardedAndRequestedAgainOnce) {
+ AgentLoopHarness h;
+ auto runs = std::make_shared>(0);
+ h.register_tool(create_counting_tool("probe", runs));
+ const nlohmann::json args = {{"command", "ls -la\ntimeout\n30"}};
+ h.push_tool_call("probe", args.dump(), "bad-args");
+ h.push_text("done");
+
+ ASSERT_TRUE(h.submit_and_wait("go"));
+ EXPECT_EQ(h.turn_count(), 2);
+ EXPECT_EQ(runs->load(), 0);
+ EXPECT_EQ(h.count_by_role("error"), 0);
+ const auto notices = notice_params_for(h.snapshot_events(), "response_corrupted_retry");
+ ASSERT_EQ(notices.size(), 1u);
+ EXPECT_EQ(notices[0].value("context", std::string{}), "tool_arguments");
+ EXPECT_EQ(notices[0].value("marker", std::string{}), "");
+}
+
+// 场景:模型在编辑本来就含这些标记的代码 —— 参数是 ACECode 自己
+// leaked_tool_markup.cpp 里的标记表。
+// 期望:不算损坏,工具照常执行一次,没有重发通知。按子串拦会让这类编辑每次都
+// 被丢弃重发、重发后照样命中,回合直接报错停下。
+TEST(AgentLoopTermination, ArgumentsQuotingTemplateMarkersStillExecute) {
+ AgentLoopHarness h;
+ auto runs = std::make_shared>(0);
+ h.register_tool(create_counting_tool("probe", runs));
+ const nlohmann::json args = {{"old_string",
+ "static constexpr std::string_view kMarkers[] = {\n"
+ " \"\", \"\", \"\", \"\"};"}};
+ h.push_tool_call("probe", args.dump(), "c-legit");
+ h.push_text("done");
+
+ ASSERT_TRUE(h.submit_and_wait("go"));
+ EXPECT_EQ(h.turn_count(), 2);
+ EXPECT_EQ(runs->load(), 1);
+ EXPECT_TRUE(notice_params_for(h.snapshot_events(), "response_corrupted_retry").empty());
+}
+
+// 场景:模型调了一个格式不规范但不像调用表达式的名字 `functions.noop`(不在
+// 工具表里,也不符合工具名字符集)。
+// 期望:不算损坏,照旧走 Unknown tool:执行一次、结果里回显这个名字并列出可用
+// 工具,没有重发通知 —— 只有空名字与调用表达式 / 模板残片才整步丢弃。
+TEST(AgentLoopTermination, MalformedButNonCorruptedNameStillGetsUnknownToolError) {
+ acecode::ScopedModelToolNameMappings none(acecode::ToolProtocolNameMappings{});
+ AgentLoopHarness h;
+ h.push_tool_call("functions.noop", "{}", "c-dotted");
+ h.push_text("done");
+
+ ASSERT_TRUE(h.submit_and_wait("go"));
+ EXPECT_EQ(h.turn_count(), 2);
+ const std::string result = first_tool_result_content(h.persisted_messages());
+ EXPECT_NE(result.find("Unknown tool: functions.noop"), std::string::npos) << result;
+ EXPECT_NE(result.find("Available tools:"), std::string::npos) << result;
+ EXPECT_TRUE(notice_params_for(h.snapshot_events(), "response_corrupted_retry").empty());
+}
+
// 场景:修复上线前落盘的老会话里有一条 assistant 消息整条是文本工具调用(测试
// stub 不经过 provider 的文本调用恢复,原样返回,等价于旧版本落盘的消息)。
// 期望:下一回合发给模型的历史里它被换成固定说明,不再出现 名字` + `` /
+// ``)转成结构化 tool_calls。2026-10-14 daemon 日志里返回的函数名原样
+// 收在 kGlm* 常量里,作为回归样例。
+// 1. 工具名:空名字、调用表达式、夹着模板标记的名字算损坏;格式正常但不存在的
+// 名字不算(那走 Unknown tool)。
+// 2. 参数:只认模板残片的结构,编辑含这些标记的文件不能被误伤。
+// 3. find_corrupted_tool_call:已注册的名字跳过名字判定,参数照查。
+
+#include
+
+#include "llm/llm_provider.hpp"
+#include "llm/tool_call_corruption.hpp"
+
+#include
+
+#include
+#include
+
+namespace {
+
+using acecode::ToolCallCorruption;
+
+// 2026-10-14 内网 daemon 日志里的真实坏名字。
+const char* const kGlmNameWithClosingTag = "skill_view(name=\"kimi-webbridge\")";
+const char* const kGlmChainedCalls =
+ "skill_view(name=\"SDD-design-analysis\")skill_view(name=\"kimi-webbridge\")"
+ "skill_view(name=\"SDD-requirement-analysis\")";
+const char* const kGlmChainedBrackets =
+ "skill_view[\"SDD-design-analysis\"]skill_view[\"kimi-webbridge\"]"
+ "skill_view[\"SDD-requirement-analysis\"]";
+
+acecode::ToolCall make_call(std::string name, std::string args = "{}") {
+ acecode::ToolCall call;
+ call.id = "call-" + name;
+ call.function_name = std::move(name);
+ call.function_arguments = std::move(args);
+ return call;
+}
+
+} // namespace
+
+// 场景:网关返回的函数名就是模型照着提示词写的伪函数调用,后面还拖着模板闭合
+// 标签,或者几段调用被串进同一个名字里(日志原样)。
+// 期望:都判为工具名损坏(TemplateMarkup),marker 是名字里最早出现的模板标记。
+// 回归:旧实现只扫正文,这些名字被当成 Unknown tool 执行并写回历史。
+TEST(ToolCallCorruption, RealGlmGatewayNamesAreCorrupted) {
+ const auto closing = acecode::diagnose_corrupted_tool_name(kGlmNameWithClosingTag);
+ ASSERT_TRUE(closing.has_value());
+ EXPECT_EQ(closing->field, ToolCallCorruption::Field::Name);
+ EXPECT_EQ(closing->reason, ToolCallCorruption::Reason::TemplateMarkup);
+ EXPECT_EQ(closing->marker, "");
+
+ for (const char* name : {kGlmChainedCalls, kGlmChainedBrackets}) {
+ const auto chained = acecode::diagnose_corrupted_tool_name(name);
+ ASSERT_TRUE(chained.has_value()) << name;
+ EXPECT_EQ(chained->reason, ToolCallCorruption::Reason::TemplateMarkup) << name;
+ EXPECT_EQ(chained->marker, "") << name;
+ }
+}
+
+// 场景:日志里的第四种形态 —— 函数名为空、参数 {},finish_reason=stop(provider
+// 打出 "synthesized missing tool_call id index=0 tool=''");另含只有空白的名字。
+// 期望:判为 EmptyName,marker 为空。
+TEST(ToolCallCorruption, EmptyOrBlankNameIsCorrupted) {
+ for (const char* name : {"", " ", "\n\t "}) {
+ const auto corruption = acecode::diagnose_corrupted_tool_name(name);
+ ASSERT_TRUE(corruption.has_value()) << "[" << name << "]";
+ EXPECT_EQ(corruption->reason, ToolCallCorruption::Reason::EmptyName);
+ EXPECT_TRUE(corruption->marker.empty());
+ }
+}
+
+// 场景:名字里没有模板标记,但明显是调用表达式(圆括号 / 方括号 / 引号 / 等号)。
+// 期望:判为 CallSyntax,marker 是第一个调用语法字符。
+TEST(ToolCallCorruption, CallExpressionNameIsCorrupted) {
+ const auto paren = acecode::diagnose_corrupted_tool_name("skill_view(name=\"kimi-webbridge\")");
+ ASSERT_TRUE(paren.has_value());
+ EXPECT_EQ(paren->reason, ToolCallCorruption::Reason::CallSyntax);
+ EXPECT_EQ(paren->marker, "(");
+
+ const auto bracket = acecode::diagnose_corrupted_tool_name("skill_view[\"x\"]");
+ ASSERT_TRUE(bracket.has_value());
+ EXPECT_EQ(bracket->marker, "[");
+
+ EXPECT_TRUE(acecode::diagnose_corrupted_tool_name("memory_read name=x").has_value());
+}
+
+// 场景:格式正常的名字(含 MCP 风格前缀),以及格式不规范但不像调用表达式的
+// 名字(大小写不对、带点的命名空间前缀、带空格、超过 64 字符)。
+// 期望:都不算损坏 —— 这些照旧走 Unknown tool,报错会列出可用工具让模型照抄;
+// 只有空名字和调用表达式 / 模板残片才整步丢弃重发。
+TEST(ToolCallCorruption, WellFormedOrMerelyUnknownNamesAreNotCorrupted) {
+ for (const char* name : {"file_read", "AskUserQuestion", "mcp_srv_list-items", "Bash",
+ "functions.skill_view", "skill view", "server/tool"}) {
+ EXPECT_FALSE(acecode::diagnose_corrupted_tool_name(name).has_value()) << name;
+ }
+ const std::string long_name(80, 'a');
+ EXPECT_FALSE(acecode::is_conventional_tool_name(long_name));
+ EXPECT_FALSE(acecode::diagnose_corrupted_tool_name(long_name).has_value());
+
+ EXPECT_TRUE(acecode::is_conventional_tool_name("skill_view"));
+ EXPECT_TRUE(acecode::is_conventional_tool_name(std::string(64, 'x')));
+ EXPECT_FALSE(acecode::is_conventional_tool_name(std::string(65, 'x')));
+ EXPECT_FALSE(acecode::is_conventional_tool_name(""));
+}
+
+// 场景:参数里漏进了模板结构 —— 值以自己的闭合标签结尾、相邻参数的
+// `…` 被吞进前一个值(上一个值缺闭合标签时
+// 服务端正则的典型结果)、键名里带标签、嵌套对象里的值、非 JSON 的模板原文。
+// 期望:全部判为参数损坏,marker 是结构开头的标记。
+TEST(ToolCallCorruption, TemplateStructureInArgumentsIsCorrupted) {
+ const auto expect_args = [](const nlohmann::json& args, const char* marker) {
+ const auto corruption = acecode::diagnose_corrupted_tool_arguments(args.dump());
+ ASSERT_TRUE(corruption.has_value()) << args.dump();
+ EXPECT_EQ(corruption->field, ToolCallCorruption::Field::Arguments);
+ EXPECT_EQ(corruption->marker, marker) << args.dump();
+ };
+ expect_args({{"name", "kimi-webbridge"}}, "");
+ expect_args({{"name", "kimi-webbridge\n"}}, "");
+ expect_args({{"command", "ls -la\ntimeout\n30"}}, "");
+ expect_args({{"command", "ls\ntimeout"}}, "");
+ expect_args({{"path", "a.txt"}}, "");
+ expect_args({{"command", "ls"}}, "");
+ const nlohmann::json edit = {{"new_string", "x"}};
+ expect_args({{"edits", nlohmann::json::array({edit})}}, "");
+
+ const auto raw = acecode::diagnose_corrupted_tool_arguments(
+ "name\nkimi-webbridge");
+ ASSERT_TRUE(raw.has_value());
+ EXPECT_EQ(raw->marker, "");
+}
+
+// 场景:模型在编辑本来就含这些标记的文件 —— ACECode 自己的
+// leaked_tool_markup.cpp 标记表、测试里的损坏样例常量、Hermes 格式的文档示例,
+// 以及参数里只出现单个标记的普通文本。
+// 期望:都不算损坏,照常执行。按子串拦会让这类编辑每次都被丢弃重发,
+// 重发后照样命中,回合直接报错停下。
+TEST(ToolCallCorruption, LegitimateContentMentioningMarkersIsNotCorrupted) {
+ const std::vector cases = {
+ {{"old_string",
+ "static constexpr std::string_view kMarkers[] = {\n"
+ " \"\", \"\", \"\", \"\"};"}},
+ {{"new_string",
+ "const char* const kCorruptedReply =\n"
+ " \" roots\\n# 3.3# 45 4}\\n40\";"}},
+ {{"content", "{\"name\": \"bash\"}\n"}},
+ {{"content", "bash\ncommand"}},
+ {{"path", "README.md"}},
+ };
+ for (const auto& args : cases) {
+ EXPECT_FALSE(acecode::diagnose_corrupted_tool_arguments(args.dump()).has_value())
+ << args.dump();
+ }
+ EXPECT_FALSE(acecode::diagnose_corrupted_tool_arguments("").has_value());
+ EXPECT_FALSE(acecode::diagnose_corrupted_tool_arguments("{}").has_value());
+}
+
+// 场景:同一步里先是一个正常调用,第二个是坏名字;另有一个已注册工具的名字
+// 恰好带括号(MCP 工具名不受字符集约束),但它的参数漏进了模板结构。
+// 期望:返回第一个损坏调用的下标;已注册的名字不按名字判,参数照查;
+// is_registered 为空时只按名字本身判。
+TEST(ToolCallCorruption, FindCorruptedToolCallSkipsRegisteredNamesButChecksArguments) {
+ const std::vector calls = {
+ make_call("file_read", R"({"path":"README.md"})"),
+ make_call(kGlmNameWithClosingTag),
+ };
+ const auto found = acecode::find_corrupted_tool_call(calls, nullptr);
+ ASSERT_TRUE(found.has_value());
+ EXPECT_EQ(found->index, 1u);
+ EXPECT_EQ(found->corruption.field, ToolCallCorruption::Field::Name);
+
+ const auto registered = [](const std::string& name) { return name == "mcp_srv_odd(v2)"; };
+ EXPECT_FALSE(acecode::find_corrupted_tool_call(
+ {make_call("mcp_srv_odd(v2)", R"({"q":"x"})")}, registered).has_value());
+ const auto bad_args = acecode::find_corrupted_tool_call(
+ {make_call("mcp_srv_odd(v2)", R"({"q":"x"})")}, registered);
+ ASSERT_TRUE(bad_args.has_value());
+ EXPECT_EQ(bad_args->corruption.field, ToolCallCorruption::Field::Arguments);
+
+ EXPECT_FALSE(acecode::find_corrupted_tool_call(
+ {make_call("functions.noop"), make_call("file_read")}, nullptr).has_value());
+}
diff --git a/tests/memory/memory_read_tool_test.cpp b/tests/memory/memory_read_tool_test.cpp
index 3512039b..ebaefdec 100644
--- a/tests/memory/memory_read_tool_test.cpp
+++ b/tests/memory/memory_read_tool_test.cpp
@@ -179,3 +179,16 @@ TEST(MemoryReadToolTest, DisabledMemoryRejectsCall) {
auto j = run(tool, "{}", acecode::ToolContext{}, false);
EXPECT_NE(j["error"].get().find("disabled"), std::string::npos);
}
+
+// 场景:按名字读取一个不存在的条目,结果带一句「怎么列出可用条目」的 hint。
+// 期望:hint 用文字描述(不带参数调用 memory_read),不出现 `memory_read(` 这种
+// 伪函数调用写法 —— GLM 系模型会把它照抄成工具名(内网 2026-10-14)。
+TEST(MemoryReadToolTest, NotFoundHintDescribesCallInProse) {
+ MemoryTestHome home("memory-read-hint");
+ auto tool = acecode::create_memory_read_tool(home.service());
+ auto j = run(tool, R"({"name":"ghost"})", acecode::ToolContext{});
+ ASSERT_FALSE(j["found"].get());
+ const std::string hint = j.value("hint", std::string{});
+ EXPECT_EQ(hint.find("memory_read("), std::string::npos) << hint;
+ EXPECT_NE(hint.find("memory_read without arguments"), std::string::npos) << hint;
+}
diff --git a/tests/prompt/memory_prompt_test.cpp b/tests/prompt/memory_prompt_test.cpp
index dd4d9027..752633ec 100644
--- a/tests/prompt/memory_prompt_test.cpp
+++ b/tests/prompt/memory_prompt_test.cpp
@@ -109,6 +109,25 @@ TEST(MemoryPromptTest, BudgetKeepsNewestEntriesAndAddsOmissionNote) {
EXPECT_EQ(read_file(workspace->dir() / "MEMORY.md"), index_before);
}
+// 场景:工作区有一条记忆,快照的使用说明里讲怎么读全文 / 搜索被省略的条目。
+// 期望:用文字描述(调用 memory_read 工具、name / query 参数),不出现
+// `memory_read(` 这种伪函数调用写法 —— GLM 系模型会把整段表达式照抄成工具名
+// (内网 2026-10-14 的 `skill_view(name="…")` 同一机理)。
+TEST(MemoryPromptTest, UsageNoteDescribesMemoryReadInProse) {
+ MemoryTestHome home("memory-prompt-prose");
+ auto memory = home.service();
+ const std::string project_dir = MemoryTestHome::project_dir(home.workspace_cwd("ws"));
+ write_at(*memory->workspace(project_dir), "build_dir", acecode::MemoryType::Project,
+ "Build into L:/build", "2026-10-01T08:00:00Z");
+
+ const auto block = snapshot(memory, project_dir, 8192, "2026-10-01T12:00:00Z");
+ const auto& text = block.content;
+ ASSERT_FALSE(text.empty());
+ EXPECT_EQ(text.find("memory_read("), std::string::npos) << text;
+ EXPECT_NE(text.find("call the memory_read tool with its name argument"),
+ std::string::npos) << text;
+}
+
// 场景:两个作用域都没有条目。期望:返回空块,请求中不出现记忆上下文。
TEST(MemoryPromptTest, EmptyScopesProduceNoBlock) {
MemoryTestHome home("memory-prompt-empty");
diff --git a/tests/prompt/skills_index_prompt_test.cpp b/tests/prompt/skills_index_prompt_test.cpp
index f423dad5..10973052 100644
--- a/tests/prompt/skills_index_prompt_test.cpp
+++ b/tests/prompt/skills_index_prompt_test.cpp
@@ -332,6 +332,26 @@ TEST_F(SkillsIndexRegistryTest, PopulatedRegistryRendersBlock) {
EXPECT_EQ(block.cache_key.rfind("skills:", 0), 0u);
}
+// 触发场景:registry 有 skill,目录块里讲解「决定用某个 skill 后怎么加载」。
+// 期望:用文字描述(调用 skill_view 工具、name 参数填技能名),不出现
+// `skill_view(name=...)` 这类伪函数调用;同样输入渲染两次逐字节相同。
+// 回归:内网 GLM 模型把提示里的 `skill_view(name="...")` 照抄成工具名,网关转换
+// 失败后返回 `skill_view(name="kimi-webbridge")`(2026-10-14 日志)。
+TEST_F(SkillsIndexRegistryTest, LoadingGuidanceUsesProseInsteadOfPseudoCalls) {
+ write_skill_md(temp_root, "review", "review-pr", "Review pull requests",
+ "Use when the user mentions a PR");
+ acecode::SkillRegistry registry;
+ registry.set_scan_roots({temp_root});
+ registry.scan();
+
+ const auto block = acecode::build_skills_index_context_prompt(®istry, 128000);
+ EXPECT_EQ(block.content.find("skill_view("), std::string::npos) << block.content;
+ EXPECT_NE(block.content.find("`name` argument set to the skill name"),
+ std::string::npos) << block.content;
+ EXPECT_EQ(acecode::build_skills_index_context_prompt(®istry, 128000).content,
+ block.content);
+}
+
// Codex 的目录发现不依赖专用 skill_view 工具;有 file locator 时即使该
// 工具被策略隐藏,目录仍应进入模型上下文并指导使用普通文件读取能力。
TEST_F(SkillsIndexRegistryTest, CatalogRemainsVisibleWithoutSkillViewTool) {
diff --git a/tests/prompt/system_prompt_test.cpp b/tests/prompt/system_prompt_test.cpp
index 7b9e7ff0..22960388 100644
--- a/tests/prompt/system_prompt_test.cpp
+++ b/tests/prompt/system_prompt_test.cpp
@@ -861,6 +861,26 @@ TEST_F(SystemPromptTest, MemoryGuidanceRoutesRememberRequestsToMemoryWrite) {
EXPECT_EQ(without_memory.find("# Memory\n"), std::string::npos);
}
+// 场景:工具表里有 skill_view / skills_list,静态提示出「# Skills」段讲怎么加载 skill。
+// 期望:调用方式用文字描述(skill_view 工具、name / file_path 参数),不出现
+// `skill_view(` 这种伪函数调用写法;同样输入两次构建逐字节相同(prompt cache
+// 前缀不变量)。
+// 回归背景(内网 GLM,2026-10-14):提示里写着 `skill_view(name="")`,模型把
+// 整段表达式照抄成工具名,网关转换失败后返回 `skill_view(name="kimi-webbridge")
+// `,被当成 Unknown tool 反复执行。
+TEST_F(SystemPromptTest, SkillGuidanceDescribesToolCallsInProse) {
+ acecode::ScopedModelToolNameMappings none({});
+ acecode::ToolExecutor tools;
+ register_probe_tools(tools, {"skill_view", "skills_list"});
+ const std::string out = acecode::build_system_prompt(tools, temp_home.string());
+
+ EXPECT_NE(out.find("# Skills"), std::string::npos);
+ EXPECT_EQ(out.find("skill_view("), std::string::npos) << out;
+ EXPECT_NE(out.find("`name` argument set to the skill name"), std::string::npos);
+ EXPECT_NE(out.find("`file_path` argument"), std::string::npos);
+ EXPECT_EQ(out, acecode::build_system_prompt(tools, temp_home.string()));
+}
+
// 场景:模型态是 gpt-5(偏好 apply_patch),工具表里 apply_patch 可用。
// 期望:出现 apply_patch 指引(相对路径 / 3 行上下文 / @@ 锚点、找不到时重读
// 而不是绕道 shell)与 "# Model-specific guidance" 段(自主推进、最小改动、脏
diff --git a/tests/session/session_history_recovery_test.cpp b/tests/session/session_history_recovery_test.cpp
index 8afb2d06..17dd24f1 100644
--- a/tests/session/session_history_recovery_test.cpp
+++ b/tests/session/session_history_recovery_test.cpp
@@ -1,5 +1,6 @@
#include
+#include "llm/message_predicates.hpp"
#include "session/session_history_recovery.hpp"
#include "session/session_serializer.hpp"
@@ -325,3 +326,133 @@ TEST(SessionHistoryRecovery, RemapsLegacySingleToolCallObjects) {
EXPECT_EQ(recovered.messages[3].tool_call_id, recovered.messages[2].tool_calls[0]["id"]);
EXPECT_EQ(recovered.messages[3].content, "second");
}
+
+namespace {
+
+// 2026-10-14 内网 GLM 网关返回的真实坏名字(服务端没能把模板转成结构化调用)。
+const char* const kGlmBrokenName = "skill_view(name=\"kimi-webbridge\")";
+
+bool contains_text(const std::vector& messages,
+ const std::string& needle) {
+ for (const auto& msg : messages) {
+ if (msg.content.find(needle) != std::string::npos) return true;
+ if (!msg.tool_calls.is_null() &&
+ msg.tool_calls.dump().find(needle) != std::string::npos) {
+ return true;
+ }
+ }
+ return false;
+}
+
+} // namespace
+
+// 场景:修复上线前落盘的会话(20261014-081557-766b 的形态):assistant 带一段正文
+// 和一个函数名为空的调用,后面跟着它的 Unknown tool 报错结果。
+// 期望:空名调用照旧从投影里去掉,但它的报错结果不再被静默丢弃,换成一条
+// 「调用未执行」的 user 说明放在这组结果之后 —— 请求不再以孤立的 assistant 消息
+// 结尾,模型也看得到失败。说明是内部上下文(不算真实用户回合起点),统计里
+// malformed_call_results 计 1、standalone_results 不再计。
+// 回归表现:日志 `[session-recovery] malformed_calls=1 ... standalone_results=1`,
+// 请求以 assistant 结尾,模型看不到失败,原样重复空名调用。
+TEST(SessionHistoryRecovery, EmptyNameCallResultBecomesNotExecutedNote) {
+ nlohmann::json empty_name = tool_call("call-empty", "");
+ empty_name["function"]["arguments"] = "{}";
+ const auto recovered = acecode::recover_provider_history({
+ text_message("user", "load the design skills"),
+ assistant_with_calls({empty_name}, "Loading the skills."),
+ tool_result("call-empty", "[Error] Unknown tool: . Available tools: skill_view"),
+ });
+
+ ASSERT_EQ(recovered.messages.size(), 3u);
+ EXPECT_EQ(recovered.messages[1].role, "assistant");
+ EXPECT_EQ(recovered.messages[1].content, "Loading the skills.");
+ EXPECT_TRUE(recovered.messages[1].tool_calls.is_null());
+ const auto& note = recovered.messages.back();
+ EXPECT_EQ(note.role, "user");
+ EXPECT_EQ(note.content, acecode::kMalformedToolCallNote);
+ EXPECT_FALSE(acecode::is_real_user_message(note));
+ EXPECT_EQ(note.metadata["session_recovery"]["kind"], "malformed_tool_call");
+ EXPECT_EQ(recovered.stats.malformed_tool_calls, 1u);
+ EXPECT_EQ(recovered.stats.malformed_call_results, 1u);
+ EXPECT_EQ(recovered.stats.standalone_tool_results, 0u);
+ EXPECT_TRUE(recovered.stats.changed());
+}
+
+// 场景:旧会话里连续三轮都是坏名字的调用(没有正文),每轮都带着回显了坏名字的
+// Unknown tool 报错 —— 修复前每轮都原样写进历史。
+// 期望:坏调用与报错都不进投影,相邻的说明合并成一条;投影里任何地方都不再出现
+// 坏名字或模板标记(GLM 服务端会把历史按模板排回提示词,等于错误示范)。
+TEST(SessionHistoryRecovery, CorruptedNameCallsAreHiddenAndNotesCollapse) {
+ std::vector input = {text_message("user", "go")};
+ for (int i = 0; i < 3; ++i) {
+ const std::string id = "bad-" + std::to_string(i);
+ input.push_back(assistant_with_calls({tool_call(id, kGlmBrokenName)}));
+ input.push_back(tool_result(
+ id, std::string("[Error] Unknown tool: ") + kGlmBrokenName +
+ ". Available tools: skill_view"));
+ }
+
+ const auto recovered = acecode::recover_provider_history(input);
+ ASSERT_EQ(recovered.messages.size(), 2u);
+ EXPECT_EQ(recovered.messages[0].content, "go");
+ EXPECT_EQ(recovered.messages[1].content, acecode::kMalformedToolCallNote);
+ EXPECT_FALSE(contains_text(recovered.messages, ""));
+ EXPECT_FALSE(contains_text(recovered.messages, "skill_view(name="));
+ EXPECT_EQ(recovered.stats.malformed_tool_calls, 3u);
+ EXPECT_EQ(recovered.stats.malformed_call_results, 3u);
+ EXPECT_EQ(recovered.stats.empty_assistant_messages, 3u);
+}
+
+// 场景:同一批里一个正常调用、一个空名调用,坏调用的报错结果先到,正常结果后到。
+// 期望:正常调用与结果保持配对相邻,说明放在这组全部结果之后,不插进
+// assistant(tool_calls) 与它的结果之间(OpenAI 兼容服务会对拆开的配对报 400)。
+TEST(SessionHistoryRecovery, NotExecutedNoteFollowsTheWholeResultGroup) {
+ const auto recovered = acecode::recover_provider_history({
+ assistant_with_calls({tool_call("call-good"), tool_call("call-bad", "")}),
+ tool_result("call-bad", "[Error] Unknown tool: "),
+ tool_result("call-good", "contents"),
+ text_message("user", "continue"),
+ });
+
+ ASSERT_EQ(recovered.messages.size(), 4u);
+ ASSERT_EQ(recovered.messages[0].tool_calls.size(), 1u);
+ EXPECT_EQ(recovered.messages[0].tool_calls[0]["id"], "call-good");
+ EXPECT_EQ(recovered.messages[1].role, "tool");
+ EXPECT_EQ(recovered.messages[1].content, "contents");
+ EXPECT_EQ(recovered.messages[2].content, acecode::kMalformedToolCallNote);
+ EXPECT_EQ(recovered.messages[3].content, "continue");
+}
+
+// 场景:坏调用的说明已经生成之后,投影再过一遍修复(provider 的 build_request_body
+// 与线程修复都会对投影再调一次)。
+// 期望:幂等 —— 不再改动、不重复加说明,逐字节相同(prompt cache 前缀稳定)。
+TEST(SessionHistoryRecovery, NotExecutedNoteProjectionIsIdempotent) {
+ const auto first = acecode::recover_provider_history({
+ text_message("user", "go"),
+ assistant_with_calls({tool_call("call-bad", kGlmBrokenName)}, "trying"),
+ tool_result("call-bad", "[Error] Unknown tool"),
+ });
+ const auto second = acecode::recover_provider_history(first.messages);
+
+ EXPECT_FALSE(second.stats.changed());
+ ASSERT_EQ(second.messages.size(), first.messages.size());
+ for (std::size_t i = 0; i < first.messages.size(); ++i) {
+ EXPECT_EQ(acecode::serialize_message(second.messages[i]),
+ acecode::serialize_message(first.messages[i]));
+ }
+}
+
+// 场景:坏调用没有落盘的结果(调用前进程被中断)。
+// 期望:沿用原行为 —— 只去掉坏调用,不凭空补说明(结果从未产生,也就没有
+// 「模型看不到失败」的问题),保持最小改动。
+TEST(SessionHistoryRecovery, MalformedCallWithoutResultAddsNoNote) {
+ const auto recovered = acecode::recover_provider_history({
+ assistant_with_calls({tool_call("call-bad", "")}, "explaining"),
+ text_message("user", "next"),
+ });
+
+ ASSERT_EQ(recovered.messages.size(), 2u);
+ EXPECT_EQ(recovered.messages[0].content, "explaining");
+ EXPECT_EQ(recovered.messages[1].content, "next");
+ EXPECT_EQ(recovered.stats.malformed_call_results, 0u);
+}
diff --git a/tests/tool/skills_tool_test.cpp b/tests/tool/skills_tool_test.cpp
index 1d648402..9c02f1c1 100644
--- a/tests/tool/skills_tool_test.cpp
+++ b/tests/tool/skills_tool_test.cpp
@@ -194,3 +194,20 @@ TEST_F(SkillsToolTest, ToolContextCwdUsesWorkspaceLocalSkills) {
}
} // namespace
+
+// 场景:列出全部 skill(正常有结果),结果里带一句「怎么加载全文」的 hint。
+// 期望:hint 用文字描述(调用 skill_view 工具、name 参数填技能名),不出现
+// `skill_view(` 这种伪函数调用写法。
+// 回归:旧 hint 是 `Use skill_view(name=...) ...`,内网 GLM 模型把整段表达式照抄成
+// 工具名,网关转换失败后返回 `skill_view(name="kimi-webbridge")`。
+TEST_F(SkillsToolTest, ListHintDescribesSkillViewInProse) {
+ acecode::SkillRegistry registry;
+ populate_registry_with_two_skills(registry);
+
+ auto out = run_tool(registry);
+
+ ASSERT_TRUE(out.contains("hint"));
+ const std::string hint = out["hint"].get();
+ EXPECT_EQ(hint.find("skill_view("), std::string::npos) << hint;
+ EXPECT_NE(hint.find("skill_view tool"), std::string::npos) << hint;
+}
diff --git a/tests/tool/tool_executor_name_resolution_test.cpp b/tests/tool/tool_executor_name_resolution_test.cpp
index f39869fb..99f13e86 100644
--- a/tests/tool/tool_executor_name_resolution_test.cpp
+++ b/tests/tool/tool_executor_name_resolution_test.cpp
@@ -119,3 +119,30 @@ TEST(ToolExecutorNameResolution, UnknownToolErrorListsAvailableNames) {
EXPECT_EQ(text.find("t100,"), std::string::npos);
EXPECT_NE(text.find("(+3 more)"), std::string::npos);
}
+
+// 触发场景:内网 GLM 网关没能把模型写的工具调用模板转成结构化调用,函数名是
+// `skill_view(name="kimi-webbridge")`(2026-10-14 日志原样),或干脆为空。
+// 期望行为:Unknown tool 报错不回显坏名字(不出现调用表达式与模板标记),空名字
+// 改说「没有工具名」,仍列出可用工具;ToolExecutor::execute 的未注册分支同样不回显。
+// 回归:旧文案 `[Error] Unknown tool: <坏名字>` 写进历史,下一轮被服务端按模板排回
+// 提示词,模型隔两轮又照抄出同一个坏名字。
+TEST(ToolExecutorNameResolution, UnknownToolErrorDoesNotEchoCorruptedNames) {
+ const std::string broken = "skill_view(name=\"kimi-webbridge\")";
+ const std::string text = acecode::ToolErrors::unknown_tool(broken, {"skill_view", "bash"});
+ EXPECT_EQ(text.find("skill_view(name="), std::string::npos) << text;
+ EXPECT_EQ(text.find(""), std::string::npos) << text;
+ EXPECT_NE(text.find("malformed"), std::string::npos) << text;
+ EXPECT_NE(text.find("Available tools: skill_view, bash"), std::string::npos) << text;
+
+ const std::string empty = acecode::ToolErrors::unknown_tool("", {"bash"});
+ EXPECT_NE(empty.find("no tool name"), std::string::npos) << empty;
+ EXPECT_EQ(empty.find("Unknown tool: ."), std::string::npos) << empty;
+ EXPECT_NE(empty.find("Available tools: bash"), std::string::npos) << empty;
+
+ acecode::ToolExecutor tools;
+ ASSERT_TRUE(tools.register_tool(make_named_tool("bash")));
+ const auto result = tools.execute(broken, "{}");
+ EXPECT_FALSE(result.success);
+ EXPECT_EQ(result.output.find(""), std::string::npos) << result.output;
+ EXPECT_EQ(result.output.find("skill_view(name="), std::string::npos) << result.output;
+}
diff --git a/web/scripts/test-side-chat-retention.mjs b/web/scripts/test-side-chat-retention.mjs
index 0655d1df..95625583 100644
--- a/web/scripts/test-side-chat-retention.mjs
+++ b/web/scripts/test-side-chat-retention.mjs
@@ -66,6 +66,11 @@ try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: 'zh-CN' });
page.on('pageerror', error => errors.push(error.message));
await page.addInitScript(() => {
+ // Playwright's WebSocket mock uses URL.parse, added after Chromium 110.
+ // Keep that test transport usable on the older Desktop WebView baseline.
+ if (!URL.parse) URL.parse = (input, base) => {
+ try { return new URL(input, base); } catch { return null; }
+ };
window.sideChatNow = Date.now();
Date.now = () => window.sideChatNow;
});
diff --git a/web/src/i18n/catalogs/systemNotice.js b/web/src/i18n/catalogs/systemNotice.js
index c15898cb..c7b6bf85 100644
--- a/web/src/i18n/catalogs/systemNotice.js
+++ b/web/src/i18n/catalogs/systemNotice.js
@@ -55,6 +55,9 @@ export const systemNoticeZhCN = {
response_empty_retry: '模型返回空回复({{reason}}),正在自动重试 {{attempt}}/{{attempts}}。',
response_text_tool_call_retry: '模型把工具调用写成了正文文本,未执行,正在要求其改用原生工具调用重发 {{attempt}}/{{attempts}}。',
response_corrupted_retry: '模型回复里混入了工具参数模板标记({{marker}}),正文与工具调用都不可信,已丢弃并重新请求 {{attempt}}/{{attempts}}。',
+ response_corrupted_retry_tool_name_empty: '模型返回的工具调用没有工具名,这一步的正文与工具调用都不可信,已丢弃并重新请求 {{attempt}}/{{attempts}}。',
+ response_corrupted_retry_tool_name: '模型返回的工具名已损坏(混入了 {{marker}}),这一步的正文与工具调用都不可信,已丢弃并重新请求 {{attempt}}/{{attempts}}。',
+ response_corrupted_retry_tool_arguments: '模型返回的工具参数里混入了工具调用模板标记({{marker}}),这一步的正文与工具调用都不可信,已丢弃并重新请求 {{attempt}}/{{attempts}}。',
response_truncated: '本回复因输出 token 上限被截断,内容可能不完整。',
turn_interjected: '当前回复因新消息插话而中断。', turn_interrupted: '用户已停止当前回复。',
iteration_limit: '已达到执行轮数上限({{limit}}),自动执行已停止。',
@@ -141,6 +144,9 @@ export const systemNoticeEnUS = {
response_empty_retry: 'The model returned an empty response ({{reason}}). Retrying {{attempt}}/{{attempts}}.',
response_text_tool_call_retry: 'The model wrote a tool call as plain text, so it was not executed. Asking it to resend through native tool calling ({{attempt}}/{{attempts}}).',
response_corrupted_retry: 'The model reply contained leaked tool-argument template markup ({{marker}}), so its text and tool calls were discarded. Requesting again ({{attempt}}/{{attempts}}).',
+ response_corrupted_retry_tool_name_empty: 'The model returned a tool call without a tool name, so this step\'s text and tool calls were discarded. Requesting again ({{attempt}}/{{attempts}}).',
+ response_corrupted_retry_tool_name: 'The model returned a corrupted tool name (containing {{marker}}), so this step\'s text and tool calls were discarded. Requesting again ({{attempt}}/{{attempts}}).',
+ response_corrupted_retry_tool_arguments: 'The model returned tool arguments containing leaked tool-call template markup ({{marker}}), so this step\'s text and tool calls were discarded. Requesting again ({{attempt}}/{{attempts}}).',
response_truncated: 'This response reached the output token limit and may be incomplete.',
turn_interjected: 'The response was interrupted by a new message.', turn_interrupted: 'The user stopped the response.',
iteration_limit: 'The iteration limit ({{limit}}) was reached; automatic execution stopped.',
diff --git a/web/src/lib/systemNotice.test.js b/web/src/lib/systemNotice.test.js
index d9f60dac..290340ad 100644
--- a/web/src/lib/systemNotice.test.js
+++ b/web/src/lib/systemNotice.test.js
@@ -175,6 +175,34 @@ run('corrupted output retry notice shows the leaked marker and attempt counters'
for (const text of [cn.text, english.text]) assert.doesNotMatch(text, /\{\{|Original fallback/);
});
+// 场景:GLM 网关没能把工具调用模板转成结构化调用,坏的是工具调用本身(名字为空、
+// 名字夹着模板标记、参数里漏进模板),daemon 用同一个 response_corrupted_retry 通知,
+// params.context 区分三种情况。
+// 期望:按 context 选对应文案(不再说「回复里混入了标记」),标题不变;
+// 空名字没有标记可显示,文案里也不能出现空括号。
+run('corrupted tool call retry notice picks the wording by context', () => {
+ const empty = notice('response_corrupted_retry', { attempt: 1, attempts: 1, marker: '', context: 'tool_name_empty' });
+ const name = notice('response_corrupted_retry', { attempt: 1, attempts: 1, marker: '', context: 'tool_name' });
+ const args = notice('response_corrupted_retry', { attempt: 1, attempts: 1, marker: '', context: 'tool_arguments' });
+ const cnEmpty = presentSystemNotice(empty, zh);
+ assert.equal(cnEmpty.title, '输出异常重试中');
+ assert.ok(cnEmpty.text.includes('没有工具名'));
+ assert.doesNotMatch(cnEmpty.text, /()/);
+ assert.ok(presentSystemNotice(empty, en).text.includes('without a tool name'));
+ const cnName = presentSystemNotice(name, zh);
+ assert.ok(cnName.text.includes('工具名已损坏(混入了 )'));
+ assert.ok(cnName.text.includes('重新请求 1/1'));
+ assert.ok(presentSystemNotice(name, en).text.includes('corrupted tool name (containing )'));
+ assert.ok(presentSystemNotice(args, zh).text.includes('工具参数里混入了工具调用模板标记()'));
+ assert.ok(presentSystemNotice(args, en).text.includes('tool arguments containing'));
+ for (const message of [empty, name, args]) {
+ for (const t of [zh, en]) {
+ const text = presentSystemNotice(message, t).text;
+ assert.doesNotMatch(text, /\{\{|Original fallback|leaked tool-argument template markup|回复里混入了/);
+ }
+ }
+});
+
run('small context retry notice preserves context without a retry-count limit', () => {
const message = notice('context_preserved_waiting');
const cn = presentSystemNotice(message, zh);