工具调用校验:按交付的 schema 比较,而非你预期的 schema

原文:https://dev.to/kenielzep97/compare-against-the-schema-they-shipped-not-the-one-you-expected-3mb8(作者 @kenielzep97)

我的测试框架(harness)标记了模型,因为它发送了错误的参数。它将模型实际发送的内容与本次运行事先承诺的内容做了比对,两者不匹配。

不匹配是真实存在的。但我据此得出的结论是错的,而比对器(comparator)无法告诉我这一点。

下面要讲的是:那个不完整的检查、取代它的新检查、为什么修复方法不是"放宽比对",以及其中将来会腐烂变质的部分。

背景设定

测试框架在模型运行前准备好一次 exec 调用,将其冻结,要求模型精确发送该对象,然后把模型实际发出的工具参数与冻结的对象进行比对。如果二者不同,运行就会 fail closed(判定失败)。这个比对就是控制手段。

预期对象是这样构建的——`5bf10ac`scripts/judgment/candidate.mjs

const expectedExecArguments = validateExecArguments({
  command: `node ${artifactPath('verifier')} ${artifactPath('payload')} ${artifactPath('manifest')}`,
});


只有一个键。来自运行收据 `RUN_004_RECEIPT.json`,以下是模型实际发送的内容,逐字引用:

{"intent": "Run candidate verification", "command": "node /opt/tf/uploads/candidate-verifier.cjs /opt/tf/uploads/candidate-payload.json /opt/tf/uploads/candidate-command-manifest.json"}


同一份收据中记录的结果:EXEC_ARGUMENTS_MISMATCH

两份契约,彼此冲突

这是我第一遍读时弄错的部分,而且它比"模型偏离了预期"更有意思。

提供者的 exec schema(模式)——来自我所运行的编译产物 @truefoundry/trueforge-core@0.1.4 中的 dist/core/sandbox/Sandbox.mjs

var sandboxExecSchema = z.object({
  intent: z.string().describe(
    "A brief, user-facing explanation of the purpose of this command. Avoid technical jargon and code details."
  ),
  command: z.string().describe("The shell command to execute in the sandbox."),
  cwd: z.string().optional().describe("Working directory for command execution."),
  env: z.record(z.string(), z.string()).optional().describe("Additional environment variables to set.")
});


四个字段。intentcommand 必填,cwdenv 可选。这是接入沙箱 exec 工具的实现 schema;我引用的是运行时实际接受的内容,而不是从会话中捕获的工具描述符。

我的测试框架的指令scripts/judgment/live.mjs,在修复前后都存在:

'Call truefoundry-system exec exactly once with this exact JSON argument object: ' + ...


当时被插值进字符串的那个对象只有一个键。

于是,模型面对两个互相矛盾的权威来源,它遵循了提供者的那个。它满足了必填字段的 schema,却没有遵从我的精确 JSON 指令,因为我的指令要求的正是 schema 所禁止的东西。 我的比对器看到一处差异,就把它报告为偏离。它没有办法表达"双方各自内部自洽,但其中一方是错的"。

这就是结论:不是说模型是对的——而是说一次不匹配只能确立差异,不能确立哪个操作数是权威的。

修复方案,以及真正要紧的部分

最诱人的修法是减少比对内容——只检查 command,忽略多余的键,然后继续。但这会让失败消失,也把控制手段一并带走。之后智能体(agent)就可以发送任何它想发的额外参数,依然通过检查。

最终落地的方案,见提交 `0220a27`

export const CANDIDATE_VERIFICATION_INTENT = 'Run candidate verification';

const expectedExecArguments = {
  command,
  intent: CANDIDATE_VERIFICATION_INTENT,
};


intent 的值由测试框架撰写且恒定不变,不是从模型发送的内容中复制来的。复制它,比对就变成了拿模型自己来检查自己。

那次提交的标题是 "Implement adopted transport A and B controls"——这个修正是搭载在一个更大的传输层改动里一起发布的,而不是作为独立修复单独提交。这一点值得说明,因为我正请你打开那次提交看看。

预期对象的门禁检查也变得更严格了:

const argumentKeys = Object.keys(expectedArguments).sort();

if (argumentKeys.length !== 2 || argumentKeys[0] !== 'command' || argumentKeys[1] !== 'intent' ||
    expectedArguments.intent !== CANDIDATE_VERIFICATION_INTENT ||
    typeof command !== 'string' || command.length === 0) {
  failures.add('EXEC_ARGUMENTS_MISMATCH');
}


三件相互独立的事,其中只有一件变了:

  • 预期对象被修正了——从一个键变成两个。
  • 预期对象的门禁检查变严格了——上面那个代码块是新增的。
  • 实际值与预期值的比对完全没变。 它原本就是这样:
const actual = parseStrictJson(call.function.arguments);
...
} else if (!canonicalJsonBytes(actual).equals(canonicalJsonBytes(prepared.expectedExecArguments))) {


这一行在修复前后完全一致。键在规范化(canonical)序列化过程中会被排序,所以这是规范化对象比对,而不是原始字节比对;JSON 键顺序不会造成虚假的不匹配。我并没有靠削弱比对器来修复一个假失败。 这正是全部重点。

我修复的范围比看起来窄

提供者的契约: intentcommand 必填,cwdenv 允许出现。

我冻结的运行契约: 恰好只有 commandintent,没有其他键,且 intent 固定为一个常量。

我这条是有意收窄的。一个宽松的提供者并不要求 harness 接受所有符合 schema 的变体——如果运行预先承诺了这两个特定参数,拒绝第三个键是合理的 harness 约束。但这是我自己的策略,不是 TrueForge 的要求;把它写成提供者的要求,就是朝相反方向犯了同样的错误。

而下面这些没有修正。 argumentKeys.length !== 2 一次硬编码了两件事:提供者当前的必填字段集合,以及我的运行中禁止可选字段的决定。如果 TrueForge 明天新增第三个必填字段,我的 harness 会拒绝一个遵守新 schema 的模型——除非我同时修改 harness。更耐用的版本会从实际的工具 schema 推导出提供者要求的字段,然后再显式地叠加更窄的 harness 策略。我没有构建这个版本。如果你复制这个模式,就把问题一起复制走了。

你可以直接抄走的检查

1. 预期对象从哪来? 如果来自你对 API 的阅读理解,它编码的是你的假设;如果来自提供者的 schema,编码的是提供者的假设。关于“什么样的调用才算合规”,只有一方具有权威性。

2. 一次失败的比较能告诉你是哪边错了吗? 我的不能。它只打印出不匹配,我得去打开提供者的源码才能找到出错的预期。一次不匹配确立的是差异,并不说明哪个操作数是权威的。

3. 当你修复一个误报失败时,检查是否变弱了? 这才是真正咬人的那个。最快的清除红色比较的办法,是少比较一些内容;而每这样一次,你都是用“让通过变得有意义”的控制力,换来一次通过的运行。

总体形态是:一个误触发的控制,不是控制太严的证据;它是一边有问题的证据,而且在动它之前你必须先弄清楚是哪一边。

代价

有两件事,我都要记录在案。第一,关于哪一边偏离了,我最初得出了错误的结论。第二,这次运行仍然没有验证通过——同一份收据上,除了参数不匹配之外,还带着 EXEC_RESPONSE_SHAPE_UNEXPECTED,而且沙箱环境里根本没有 JavaScript 运行时。那一半我单独写了。

如果你和模型之间存在一次比较,去读一读你正在比较的 schema,检查你的预期对象是否满足它。这只需一分钟,却能省下你整个下午去调查一个模型——那个模型满足了提供者的工具契约,而我自己的指令却和它冲突。


之前:[`5bf10ac`](https://github.com/keniel13-ui/self-correcting-integration-maintainer/commit/5bf10acd7a6c0dd80e90d99217c2610df0d86d74)。修正包含在:[`0220a27`](https://github.com/keniel13-ui/self-correcting-integration-maintainer/commit/0220a27)。模型参数与结果来自 [`RUN_004_RECEIPT.json`](https://github.com/keniel13-ui/self-correcting-integration-maintainer/blob/main/docs/freezes/RUN_004_RECEIPT.json)。提供者的 schema 读取自编译后的 `@truefoundry/trueforge-core@0.1.4` 产物(在我自己的 `node_modules` 中),而非来自上游源码。同一次运行的运行时部分见 [I Built an Agent That Marked Its Own Finding as Already Known](https://dev.to/kenielzep97/a-finding-is-not-a-discovery-gib)

原文:https://dev.to/kenielzep97/compare-against-the-schema-they-shipped-not-the-one-you-expected-3mb8(作者 @kenielzep97)

发布评论
全部评论(0)