这次问题最容易被忽略的地方,是生成出来的 JSON 看上去没有任何异常。
没有语法错误,没有乱码,也没有把 false 写成字符串。接口路径、参数和说明都在,一份 OpenAPI 文档能够正常解析。
但业务方明确写下的两个意思,在生成接口声明时丢了:一个是"这个操作不是只读",另一个是"这个操作被明确标记为低风险"。
它们不会让 JSON 解析器报错,却会让接收声明的系统作出不同解释。
这是百灵中枢业务接入 SDK 中一次已经修复的问题。本文用公开的 Core v0.10.0 和 v0.10.1 做对照,复现它如何发生、为什么只修一处不够,以及怎样确认真正交到使用者手里的包也包含修复。
一、先复现:写进去的值,为什么没有输出?
百灵中枢的 PHP7 接入 SDK 允许开发者通过 ToolDef 描述一项业务能力,再由 ToolSpec 生成带有 ACC 声明的 OpenAPI 文档。
ACC,即 Agent Capability Contract,用来表达这些能力面向 Agent 开放时的约定。这里先关注两个字段:execution.readonly 表示是否只读,risk.level 表示声明的风险等级。
下面是可运行的最小复现。保存为 example.php,执行 php example.php /path/to/bailing-connect-php7。示例只生成声明,不调用业务接口。
php
<?php
require_once $argv[1] . '/src/ToolDef.php';
require_once $argv[1] . '/src/ToolSpec.php';
use Bailing\Connect\ToolDef;
use Bailing\Connect\ToolSpec;
function capability($method, $readonly, $risk)
{
$spec = ToolSpec::create('Serialization demo')->tool(
'demo_action', $method, '/demo-action', 'example.record.process',
'Synthetic declaration only; no business request is executed',
function (ToolDef $t) use ($method, $readonly, $risk) {
if ($method === 'GET') $t->query('record_id', 'integer', true);
else $t->body('record_id', 'integer', true);
if ($readonly !== null) $t->readonly($readonly);
if ($risk !== null) $t->risk($risk);
}
);
$json = json_decode($spec->buildJson(false), true);
return $json['paths']['/demo-action'][strtolower($method)]['x-agent-capability'];
}
echo json_encode(array(
'GET_false_unset' => capability('GET', false, null),
'POST_unset_low' => capability('POST', null, 'low'),
), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), "\n";
这里的 null 只是测试函数约定的"不要调用对应 setter",不会作为字段值写入声明。
第一组输入明确调用了 readonly(false);第二组输入明确调用了 risk('low')。然而,在旧版中,两组输出的 x-agent-capability 都只剩下:
json
{
"version": 1,
"enabled": true,
"scope": "example.record.process"
}
两个声明仍分别位于 GET 和 POST 操作下面,所以接收方仍然知道 HTTP 方法;丢掉的是开发者在方法之外作出的明确说明。
修复后,第一组会保留 "execution": {"readonly": false},第二组会保留 "risk": {"level": "low"}。
问题发生在输出 JSON 之前:对象里的信息已经被生成器删掉,JSON 编码器只是如实编码了这个不完整的对象。
二、字段省略之后,并不是"什么都不发生"
要理解影响,必须把接收方的缺省规则一起看。
按照 ACC v1.0.6 的 OpenAPI 绑定,明确声明优先;没有明确风险等级时,再按只读属性推导风险。GET 可以在没有相关显式声明时提供只读缺省信号,未声明只读的其他操作默认按 medium 解释。
因此,同一份文档中"字段不存在"和"字段存在且为 false",会走到不同的分支:
| 开发者输入 | 应有解释 | 旧生成器丢字段后的解释 |
|---|---|---|
| GET,未设置 readonly,未设置 risk | 按 GET 缺省,只读、low | 相同 |
| GET,明确 readonly=false,未设置 risk | 非只读、medium | false 被省略,退回 GET 缺省,变为只读、low |
| POST,未设置 readonly,未设置 risk | 非只读、medium | 相同 |
| POST,未设置 readonly,明确 risk=low | 保留明确的 low | low 被省略,退回 medium |
| POST,明确 readonly=true,明确 risk=high | 只读声明保留,但风险仍以明确的 high 为准 | 这组不是上述两个问题的触发点 |
这张表有两个值得注意的地方。
第一,两处错误的方向相反。前一个可能把操作理解得更宽松,后一个则把明确的低风险声明解释得更保守。修复的目标是恢复声明本意,不能简单概括成"把风险调高"或"把风险调低"。
第二,GET 只是绑定中的缺省信号,不能压过明确的 readonly:false。新接口应当正确设计 HTTP 方法;面对存量接口,声明生成器也不能把业务方写下的非只读信息抹去。
这里确认的是可复现的声明差异和解释差异,不能仅凭它们断言发生过生产越权或实际审批事故。
三、根因:把"未设置"和"明确设置成默认值"合并了
旧版 PHP7 生成器的关键逻辑如下,省略了与本问题无关的字段处理:
php
$risk = isset($o['risk']) ? $o['risk'] : 'low';
if ($risk !== 'low') {
$capability['risk'] = array('level' => $risk);
}
$readonly = array_key_exists('readonly', $o)
? $o['readonly'] : ($method === 'GET');
if ($readonly && $method !== 'GET') {
$execution['readonly'] = true;
}
读这段代码,会发现它在尝试做一种常见的优化:既然有默认值,就少输出几个字段。
问题在于,某个值能不能省略,取决于接收方在当前上下文里会补成什么。
low 并不是所有操作统一的缺省风险。对于没有只读声明的 POST,接收方会补成 medium,所以不能因为生成器自己选了 low 作为临时默认值,就删掉开发者明确填写的 low。
只读字段也一样。旧代码虽然读取到了 false,后面的输出条件却只允许 true 通过。GET 上明确的 false 就这样消失了;GET 上明确的 true 也会被省略,只是后者的解释结果恰好与缺省相同,更不容易被发现。
这不是 PHP 不会处理布尔值。更准确地说,是生成器没有完整表达三种状态:
text
没有设置 → 由契约的缺省规则解释
明确 false → 保留业务方的否定声明
明确 true → 保留业务方的肯定声明
布尔值只有两种取值,可选布尔字段却至少存在这三种输入状态。把它们提前压成一个普通布尔值,后面就很难恢复"开发者到底有没有明确表达过"。
四、修复不复杂,但要同时守住生成端和消费端
PHP7 的修复首先保留字段是否被设置这一事实。下面是修复后的对应输出逻辑:
php
if (isset($o['risk'])) {
$capability['risk'] = array('level' => $o['risk']);
}
$execution = array();
if (array_key_exists('readonly', $o)) {
$execution['readonly'] = $o['readonly'];
}
if (array_key_exists('idempotent', $o)) {
$execution['idempotent'] = $o['idempotent'];
}
没有调用 setter,就不替业务方填值;明确设置了 false、true 或 low,就保留。完整实现还会把非空的 $execution 写回声明。修复后的 ToolSpec 源码
这里使用 array_key_exists 是为了明确检查字段存在性。也不应因此得出"PHP 的 isset 会丢 false"这个错误结论:isset 对已存在的 false 同样返回 true。本次旧代码的直接问题,是输出条件和提前补默认值的设计。
同一轮排查也覆盖了其他语言的生成器。PHP 8 注解版将风险字段从默认 'low' 改为可空字符串,以 null 表达未设置;这里的 null 留在构建对象中,输出时仍然省略字段。Node.js、Python、Go、Java、.NET 也需要分别保留未设置与显式值的区别,而不是机械照搬 PHP 的写法。
不过,生成端保留了字段,还不能说明接收方会尊重它。
旧的 Core OpenAPI 编译路径里,还有这样一行:
ts
const ro = m === 'GET' ? true : ann.readonly === true;
它让 GET 方法先于显式声明作出决定。修复后改为:
ts
const ro = ann.readonly ?? (m === 'GET');
?? 仅在左侧为 null 或 undefined 时使用后面的缺省值,所以明确的 false 会保留。在这一步之前,字段读取也必须正确:原来只保留 === true 的逻辑,会把 false 变成未设置;修复后改为检查值是否为布尔类型,同时保留 true 和 false。
随后推导风险时,也要先采用明确风险等级;没有明确等级,再使用已经解析好的只读属性,不能又拿 GET 将它覆盖一次。Core 编译路径 · 字段读取及风险推导
因此,这次修复有两个方向:SDK 要完整表达,运行时要正确解释。只升级 SDK,旧运行时仍可能覆盖 false;只升级运行时,也无法恢复已经被旧 SDK 丢掉的信息。
五、怎么验证:24 组输入,要检查三层结果
只为发现问题的两组输入加测试,覆盖面仍然不够。更直接的办法是展开完整组合:
text
HTTP 方法:GET / POST 2 种
readonly:未设置 / false / true 3 种
risk:未设置 / low / medium / high 4 种
2 × 3 × 4 = 24 组
每一组都走真实的 ToolDef → ToolSpec → buildJson(false),再把输出 JSON 解析回来。测试不能自己写一个"简化版生成器",最后只证明简化版是对的。
接着分别检查三件事:
- 字段是否存在。 输入未设置,输出应省略;输入明确设置,输出应保留。
- 字段值是否一致。
false仍是布尔 false,low仍是 low。 - 按契约解释后的风险是否符合原始输入。 期望值从输入独立计算,不能拿被测输出倒推"正确答案"。
例如,下面这段是风险优先级的解释模型。undefined 表示未设置,代码用于说明测试逻辑,不代替生产解析器:
ts
const expectedReadonly = input.readonly ?? (method === 'GET');
const expectedRisk = input.risk ?? (expectedReadonly ? 'low' : 'medium');
const wireReadonly = output.execution?.readonly ?? (method === 'GET');
const wireRisk = output.risk?.level ?? (wireReadonly ? 'low' : 'medium');
assert.equal(wireRisk, expectedRisk);
为什么不能只检查最后一行?因为 GET 上明确的 readonly:true 即使被丢掉,最后也可能推导出同样的 low;只比较最终风险,会漏掉输入信息丢失。
反过来,只检查 JSON 能解析、字段类型符合 Schema,也不足以发现这个问题。允许省略的字段消失后,结构仍可能合法。我们需要验证的是输入到输出再到解释的全过程。
此外,测试加入了 GET/POST × 幂等性未设置/false/true 的 6 组情况,以及显式 low、false 与主体要求、条件审批同时存在的断言,确认这些声明没有在生成时被一起删掉。幂等性提示本身不构成"超时就可以重试"的保证。
生成器测试之外,项目还将 PHP7 和 PHP8 生成的真实声明送进 Core 的 OpenAPI 编译器,核对实际消费结果,而不只停留在上述解释模型。生成器回归 · Core 消费回归
六、源码通过了,还要检查用户实际下载的包
修复源码之后,仍然有一个非常现实的问题:使用者拿到的压缩包,可能还停留在旧版本。
所以验证需要走到最后一段:下载正式发布的包,核对文件清单和内容,再用解压出来的 SDK 运行相同测试。
为了写这篇复盘,2026 年 11 月 13 日又做了一次独立目录下的复现。三个对象使用同一份 v0.10.1 PHP7 回归脚本,本机运行环境为 PHP 8.3.30:
| 验证对象 | 结果 |
|---|---|
v0.10.0 PHP7 SDK 源码 |
171 项检查中,46 项断言失败,复现旧行为 |
v0.10.1 PHP7 SDK 源码 |
171 项全部通过 |
从 GitHub Release 重新下载并解压的 v0.10.1 PHP7 包 |
171 项全部通过 |
46 是失败断言数,不是 46 个独立漏洞。171 项检查覆盖上述声明组合、幂等性及相关字段断言,也不是整个中枢的全部测试。
重新下载的包共 17 个文件,与固定版本 SDK 源码的文件集合、逐文件内容均一致。对应信息如下:
text
Core 提交:51cd4668e1129b3b34af282f4fb5b32559390f1e
配套包:bailing-connect-php7.tgz
SHA256:76458dc61ade720290042bbffac822e6df346e4ac2a9b17f150902a38e27e6a0
PHP7 SDK 在 PHP8 上通过,并不能单独证明 PHP7 兼容性。因此还对照了 11 月 5 日发布验证记录:当时源码和实际配套包都在 PHP 7.3.33 下通过同一组 171 项检查。本文将这份历史兼容性记录与本次 PHP8 复跑分开,不把它们写成同一次执行。
读者可以从 Core v0.10.1 正式发布页 获取配套包。解压后进入 bailing-connect-php7 目录执行:
sh
php tests/declaration-serialization.php
至此,才有了"输入表达正确、生成结果正确、实际消费经过检查、下载制品包含修复"这条可核对的链路。它证明本次修复的交付情况,并不替代业务系统自己的权限验收。
七、这件小事,为什么和 A2B 有关?
如果一份 JSON 只用于展示资料,丢失字段可能表现为页面少一项信息。进入 Agent 办事的链路后,同一个字段还可能参与工具暴露、审批策略和执行约束的判断。
这也是我们讨论 A2B 时关心的具体问题:当 AI 受委托,通过系统操作产生业务后果,业务方表达的约束,能否一路保留到执行环节?
在这次案例中,业务方已经写明了意思,模型不需要再"猜得更聪明"。真正需要补的是工程链路:生成器不要丢值,运行时不要擅自覆盖,分发包不要落后于源码。
ACC 为这条链路提供共同的字段含义和缺省规则。百灵中枢则需要在业务 SDK、声明编译和执行治理中落实这些约定。A2B 所讨论的问题,在这里落到了共同约定和可验证的实现上:业务方明确声明的边界,应当在每一段传递中保持一致。
同样,risk:low 也不等于"谁都能调用",更不等于免审批。主体要求、审批条件和业务系统的最终权限判断仍需各自执行。本次修复保留明确值,没有将所有写操作统一改为 low,也没有修改 ACC 的规范语义或声明中的 version:1。
八、升级时,应该看哪些差异?
对接入方而言,这次变化不能只看作 JSON 多了几个字段。以前由于字段丢失而被解释为 medium 的操作,现在可能恢复为业务方明确写下的 low;开发者要确认,这确实是自己希望表达的风险等级。
实际更新可以按以下顺序检查:
- 记录当前 Core、业务接入 SDK 和下载包的版本,明确生成端与消费端各在哪里。
- 更新相关组件后重新生成声明,比较字段的存在性、取值和有效风险,重点看显式 false 与 low。
- 核对原有主体要求、审批条件和业务权限,确认新增输出没有被误当作放宽权限的依据。
- 在测试环境刷新工具来源并验收;历史已审批请求及原始参数不应因这次声明修复被改写或重放。
具体范围可以对照 v0.10.1 升级说明。自行编写 OpenAPI,或使用其他运行时的项目,也应分别检查声明与消费逻辑,不能仅凭 SDK 版本号推断整个链路的状态。
这次修复留下的一条经验,很适合用来审查其他生成器:
当代码准备省略一个字段时,先问:接收方会在什么条件下,把它补成什么?
如果答案与业务方明确写下的值不同,这就已经改变了声明的含义。AI 接入业务系统的可靠性,也需要从这些看起来并不起眼的地方建立起来。