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);