Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

**输出损坏重发一次(`<arg_value>`)。** 回复正文里(代码块与行内代码之外)出现工具参数模板标记 `<arg_key>` / `<arg_value>` 及闭合标签(`find_leaked_tool_argument_markup`),说明网关把模板残片吐进了输出流,这一步的正文与同一回复里的原生工具调用都不可信:TurnRunner 在执行工具之前整条丢弃(不入历史、不执行)、清掉 TUI / Web 的流式草稿、发 `response_corrupted_retry` 通知后原样重发;每步连续最多 1 次(`ResponseRecoveryState::corrupted_output_retries`,产出有效工具批次后清零),重发后仍损坏就按原流程继续。`</think>` 刻意不算 —— 把推理写进正文的模型会合法输出它(用户拍板:think 残片不处理)。起因:反馈 huangyuan816,坏回复里的 bash 命令也夹着乱码照样执行,之后整个回合答非所问。
**输出损坏重发一次(`<arg_value>`、坏工具调用)。** 两类都说明网关把模板残片吐进了输出流,这一步的正文与同一回复里的全部工具调用都不可信:(1) 回复正文里(代码块与行内代码之外)出现工具参数模板标记 `<arg_key>` / `<arg_value>` 及闭合标签(`find_leaked_tool_argument_markup`);(2) 工具调用本身坏了([tool_call_corruption.hpp](src/domain/llm/tool_call_corruption.hpp)):名字为空,或名字不符合 `^[A-Za-z0-9_-]{1,64}$` 且夹着 GLM 模板标记(`<tool_call>` / `<arg_key>` / `<arg_value>` 及闭合)或调用语法(括号、方括号、引号、等号),已注册的名字不按名字判;参数只认模板残片的**结构**(值以 `</arg_value>` 结尾、`</arg_key>` 后紧跟 `<arg_value>`、`</arg_value>` 后紧跟下一段模板、键名带标记、非 JSON 且含标记)。**不要**把参数检查改成子串一出现就拦:编辑本来就含这些标记的文件(ACECode 自己的源码和测试)会被每次丢弃重发、重发后照样命中而报错停下。命中后:TurnRunner 在执行工具之前整条丢弃(不入历史、不执行)、清掉 TUI / Web 的流式草稿、发 `response_corrupted_retry` 通知后原样重发(工具调用损坏的通知带 `context` = `tool_name_empty` / `tool_name` / `tool_arguments`,Web 按 i18next context 选 `response_corrupted_retry_<context>` 文案);每步连续最多 1 次(`ResponseRecoveryState::corrupted_output_retries`,产出有效工具批次后清零),重发后仍损坏就报错停止。格式正常但不存在的名字(`Bash`、`functions.x`)不算损坏,照旧走 Unknown tool;`ToolErrors::unknown_tool` 对空名字 / 损坏名字不回显原名。`</think>` 刻意不算 —— 把推理写进正文的模型会合法输出它(用户拍板:think 残片不处理)。起因:反馈 huangyuan816,坏回复里的 bash 命令也夹着乱码照样执行,之后整个回合答非所问;内网 wizard-ai 网关 aicoder-pro(GLM 5.2 FP8,2026-10-14)高峰期没能把模型写的工具调用模板转成结构化调用,`skill_view(name="kimi-webbridge")</arg_value>` 这类名字被当成 Unknown tool 执行,坏名字连同回显它的报错写回历史,又被服务端按模板排回提示词,会话 20261014-081557-766b 连续空转 13 轮。

旧会话里已经落盘的坏调用由历史投影兜底(`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。

Expand Down
5 changes: 4 additions & 1 deletion src/adapters/tool/memory_read_tool.cpp
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -165,7 +166,9 @@ ToolImpl create_memory_read_tool(std::shared_ptr<MemoryService> 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};
}
Expand Down
6 changes: 5 additions & 1 deletion src/adapters/tool/skills_tool.cpp
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 +
Expand Down
15 changes: 14 additions & 1 deletion src/adapters/tool/tool_errors.hpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#pragma once

#include "llm/tool_call_corruption.hpp"
#include "llm/tool_protocol_names.hpp"

#include <string>
Expand Down Expand Up @@ -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="…")</arg_value>` 隔两轮又被照抄出来)。
static std::string unknown_tool(const std::string& name,
const std::vector<std::string>& 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: ";
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/tool/tool_executor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
141 changes: 141 additions & 0 deletions src/domain/llm/tool_call_corruption.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#include "tool_call_corruption.hpp"

#include <nlohmann/json.hpp>

namespace acecode {

namespace {

constexpr std::string_view kToolCallOpen = "<tool_call>";
constexpr std::string_view kToolCallClose = "</tool_call>";
constexpr std::string_view kArgKeyOpen = "<arg_key>";
constexpr std::string_view kArgKeyClose = "</arg_key>";
constexpr std::string_view kArgValueOpen = "<arg_value>";
constexpr std::string_view kArgValueClose = "</arg_value>";
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<std::string> 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<std::string> find_corrupted_json_text(const nlohmann::json& value) {
if (value.is_string()) return find_template_structure(value.get_ref<const std::string&>());
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<std::string> 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<ToolCallCorruption> 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<ToolCallCorruption> 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<std::string> 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<CorruptedToolCall> find_corrupted_tool_call(
const std::vector<ToolCall>& calls,
const std::function<bool(const std::string&)>& 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
70 changes: 70 additions & 0 deletions src/domain/llm/tool_call_corruption.hpp
Original file line number Diff line number Diff line change
@@ -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_call>名字` 加成对的 `<arg_key>` /
// `<arg_value>`),由服务端解析成结构化 tool_calls。高峰期解析失败,返回的函数名
// 变成 `skill_view(name="kimi-webbridge")</arg_value>`、几段调用串在同一个名字
// 里,或者干脆是空名字 + `{}`。这类调用执行了也只会报 Unknown tool,坏名字还会
// 写回历史,下一轮被服务端按模板排回提示词,等于给模型看错误示范。

#include "llm/llm_provider.hpp"

#include <cstddef>
#include <functional>
#include <optional>
#include <string>
#include <string_view>
#include <vector>

namespace acecode {

// 工具名字符集 ^[A-Za-z0-9_-]{1,64}$(与「工具重写」模型侧名同一规则,也是
// OpenAI / Anthropic 对 function name 的约束)。
bool is_conventional_tool_name(std::string_view name);

// 文本里最早出现的 GLM 工具调用模板标记(<tool_call> </tool_call> <arg_key>
// </arg_key> <arg_value> </arg_value>),没有返回空。
std::optional<std::string> 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<ToolCallCorruption> diagnose_corrupted_tool_name(std::string_view name);

// 参数损坏:只认模板残片的结构,不认单个标记。模型要编辑的文件本身可能含这些
// 标记(ACECode 自己的源码和测试就有),子串一出现就拦,这类编辑会永远失败。
// 命中条件:
// - 参数不是合法 JSON,且含任一 GLM 标记(这份参数本来就没法用);
// - JSON 对象的键名含任一 GLM 标记(键名是参数名,不会合法地带标签);
// - 字符串值里出现「`</arg_key>` 后(可隔空白)紧跟 `<arg_value>`」或
// 「`</arg_value>` 后紧跟 `<arg_key>` / `<tool_call>` / `</tool_call>`」
// (相邻参数的模板漏进了这个值),或值以 `</arg_value>` 结尾(值自己的
// 闭合标签没剥掉)。
std::optional<ToolCallCorruption> diagnose_corrupted_tool_arguments(std::string_view arguments);

struct CorruptedToolCall {
std::size_t index = 0; // 在 calls 中的下标
ToolCallCorruption corruption;
};

// 逐个检查一步回复里的工具调用,返回第一个损坏的。is_registered 判为已注册的
// 名字不按名字判(参数照查);is_registered 为空时只按名字本身判。
std::optional<CorruptedToolCall> find_corrupted_tool_call(
const std::vector<ToolCall>& calls,
const std::function<bool(const std::string&)>& is_registered);

} // namespace acecode
Loading
Loading