深度解析team_create双参冲突死循环:基于 Oh My OpenAgent 的 inline_spec 优先级修复实证
【免费下载链接】oh-my-openagentOmO: Just type "mass ulw" keyword with your prompt. Now you are the master of graph engineering.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent
team_create是 Senpi 团队生命周期中的核心工具,用于创建多智能体协作团队。本文基于仓库中.omo/evidence/20260804-team-create-recovery/目录下的完整诊断与修复记录,深入剖析一个真实发生、反复出现的模型侧故障:模型在调用team_create时同时传入team_name与inline_spec两个互斥参数,导致工具反复返回invalid_arguments,即使模型已正确理解并复述互斥规则,仍然持续重试直至死循环。文章将还原故障现场、分析根因、讲解inline_spec优先级修复方案及其验证过程。
故障现场还原
主会话:同样的错误重复七次
在原始会话019fcb34-df92-78b4-af23-747951793586中,模型(kimi-k3-ultrafast-unlocked,走openai-completions兼容通道)对team_create工具连续发起七次调用:
| 调用 | 参数形态 | 结果 |
|---|---|---|
team_create:23 | team_name+inline_spec | invalid_arguments |
team_create:24 | 仅team_name | 命名规格未找到 |
team_create:25 | team_name+inline_spec | invalid_arguments |
team_create:26 | team_name+inline_spec | invalid_arguments |
team_create:27 | team_name+inline_spec | invalid_arguments |
team_create:28 | team_name+inline_spec | invalid_arguments |
team_create:29 | team_name+inline_spec | invalid_arguments |
team_create:30 | team_name+inline_spec | invalid_arguments |
更关键的是,从:25到:30,模型的推理文本里明确写着自己要移除team_name,但最终发出的工具参数中仍然同时包含两个字段。每次失败结果的isError都为false——即模型并未把失败视为硬错误,于是陷入「理解规则 → 声称修正 → 实际依旧双字段 → 再次失败」的循环。
独立会话:并非偶发
在另一独立会话019fc0fb-d81b-7d40-9e58-c1ae3130f858(同样的模型/提供商路径)中,双字段调用在team_create:39、:46、:47、:49再次出现。两个独立 Kimi 会话重复相同形态的调用,说明这不是单次生成抖动,而是模型对「两个可选兄弟字段 + 散文式互斥说明」这一参数模式的系统性失稳。
根因分析:为什么模型会反复犯错
问题一:XOR 规则只存在于散文和运行时校验中
修复前的TeamCreateParams暴露了两个可选的兄弟字段(见 lifecycle.ts):
team_name:指定命名团队规格(项目.omo/teams或omo.json);inline_spec:内联团队规格。
「二者只能选其一」的互斥(XOR)规则,此前只存在于工具描述的散文文本与runTeamCreate的运行时校验里:
const hasName = params.team_name !== undefined && params.team_name.length > 0 const hasInline = params.inline_spec !== undefined if (!hasName && !hasInline) { return toolErrorResult("Provide team_name or inline_spec.", { kind: "invalid_arguments", reason: "provide team_name or inline_spec" }) }问题二:schema 层面的oneOf/anyOf在此路径不可靠
一个直觉上的修复方向是在 JSON Schema 层面声明互斥(如oneOf)。但诊断记录明确指出:这条路在此处走不通。Senpi 的 Moonshot 兼容性归一化(见兄弟路径packages/ai/src/utils/tool-schema-compat.ts)会展平根级对象联合类型,且只保留所有分支的共同要求。这意味着oneOf/anyOf的互斥语义在归一化后会被稀释甚至丢失,模型看到的 schema 依旧允许双字段共存。
问题三:把结果标记为错误治标不治本
另一个候选方案是把invalid_arguments的isError置为true,让模型把失败当作硬错误处理。但诊断明确指出:主会话的模型已经读懂了文本错误、已经正确地复述了互斥规则,却依旧输出相同的参数形态。问题不在「模型没有意识到错误」,而在「模型受 schema 诱导反复产生双字段输出」。因此强化错误标记只会让模型更频繁地撞上同一堵墙。
修复方案:让inline_spec成为权威来源
决策原则
修复的核心理念是把互斥从「运行时拒绝」改为「确定性优先级」:
只要
inline_spec存在,就以它为权威来源;team_name仅在未提供inline_spec时才被使用。更丰富的内联载荷可以在第一次过度指定调用时就执行,而不是进入由模型驱动的重试循环。
这一决策同时满足了诊断中的三个约束:不新增抽象层、不改变无关错误语义、保持所有边界情况。
源码实现
修复后的runTeamCreate(lifecycle.ts)核心逻辑如下:
export async function runTeamCreate(service: TeamToolsService, params: TeamCreateInput): Promise<ToolExecutionResult<TeamCreateDetails>> { const hasName = params.team_name !== undefined && params.team_name.length > 0 const hasInline = params.inline_spec !== undefined if (!hasName && !hasInline) { return toolErrorResult("Provide team_name or inline_spec.", { kind: "invalid_arguments", reason: "provide team_name or inline_spec" }) } let inlineSpec: unknown if (hasInline) { const coerced = coerceInlineSpec(params.inline_spec) if (!coerced.ok) { return toolErrorResult(coerced.reason, { kind: "invalid_arguments", reason: coerced.reason }) } inlineSpec = coerced.spec } try { const result = await service.createTeam( hasInline ? { inlineSpec } : { teamName: params.team_name }, ) // ... } catch (error) { if (error instanceof SenpiTeamSpecError) return toolErrorResult(error.message, { kind: "spec_error", code: error.code, reason: error.message }) if (error instanceof SenpiTeamRuntimeError) return toolErrorResult(error.message, { kind: "runtime_error", code: error.code, reason: error.message }) throw error } }关键变化点:
- 双字段时以
inline_spec为准:hasInline ? { inlineSpec } : { teamName: params.team_name },服务调用只携带内联规格; invalid_arguments保留两种场景:两个字段都没有、或inline_spec是非法 JSON 字符串(coerceInlineSpec负责把 JSON 字符串自动解析为对象);- 错误分类不变:
spec_error(规格无效)、runtime_error(派生/边界失败)的语义保持原样。
参数 schema 同步更新
工具描述与参数 schema 同步说明了新语义(lifecycle.ts):
export const TeamCreateParams = Type.Object({ team_name: Type.Optional( Type.String({ description: "Named team spec (project .omo/teams or omo.json) to create. Ignored when inline_spec is also provided." }), ), inline_spec: Type.Optional( Type.Union([InlineTeamSpecSchema, Type.String({ description: "The same spec as a JSON string; parsed automatically. Passing the object form is preferred." })], { description: "Inline team spec, e.g. { name, members: [{ name, category|subagent_type, prompt? }] }. A JSON string of the same object is also accepted and parsed automatically. Takes precedence when team_name is also provided.", }), ), })inline_spec支持两种形态:
- 对象形态(推荐):
{ name?, members: [...] },其中members既可以是数组,也可以是单个成员对象(自动包裹为数组); - JSON 字符串形态:同一对象的 JSON 字符串,由
coerceInlineSpec自动解析,解析失败返回invalid_arguments。
CREATE_DESCRIPTION也明确写出「inline_spectakes precedence when both are provided」(双字段时inline_spec优先)。
内联规格的成员定义
InlineTeamSpecMemberSchema(lifecycle.ts)定义了内联成员的字段:
| 字段 | 类型 | 说明 |
|---|---|---|
name | 可选字符串 | 成员名,团队内唯一,按小写词干归一化 |
kind | 可选枚举 | category/subagent_type/agent;agent是subagent_type的别名,缺省时按category/subagent_type字段推断 |
category | 可选字符串 | 以 category 路由该成员 |
subagent_type | 可选字符串 | 以指定 agent 定义运行该成员 |
prompt | 可选字符串 | 成员指令,必须用英文书写 |
task_summary | 可选字符串 | 成员任务的一句话摘要,展示在任务页脚/小组件 UI 中,超长会被强制截断到 80 字符(TASK_SUMMARY_MAX_LENGTH) |
团队本身有两点约定:name缺省时自动派生内联名;当前会话始终是 lead,不要声明 lead 成员。
边界情况与回归测试
行为矩阵
修复后的runTeamCreate保持所有边界语义:
| 输入 | 行为 |
|---|---|
仅team_name | 走命名规格查找 |
仅inline_spec | 走内联规格创建 |
| 两者都没有 | 返回invalid_arguments |
inline_spec为非法 JSON 字符串 | 在服务调用之前即失败,返回invalid_arguments |
| 双字段同时提供 | inline_spec优先,直接执行内联创建 |
回归测试:先红后绿
修复采用 TDD 流程,新增测试lifecycle-precedence.test.ts(packages/senpi-task/src/tools/team/lifecycle-precedence.test.ts):
describe("team_create inline precedence", () => { test("#given both team_name and inline_spec #when team_create runs #then inline_spec is authoritative", async () => { // given const inlineSpec = { name: "inline-team", members: [] } const service = createFakeTeamService({ createTeam: async () => fakeCreateResult() }) // when const result = await runTeamCreate(service, { team_name: "stale-named-team", inline_spec: inlineSpec }) // then expect(result.details.kind).toBe("created") expect(service.calls).toEqual([{ method: "createTeam", args: [{ inlineSpec }] }]) }) })- RED 阶段:在生产代码修改前运行该测试,结果为
Expected: "created"/Received: "invalid_arguments"(见 red-focused-test.txt)。测试精确复现了真实故障载荷——同时携带team_name: "stale-named-team"与完整的内联规格,与恢复出的 Kimi 会话参数形态一致; - GREEN 阶段:修复后测试通过,并断言服务收到的调用只包含
inlineSpec,验证了「双字段时inline_spec权威」的语义,而不只是「不报错」。
端到端验证:真实 Senpi 进程 + 本地模拟提供商
单元级回归证明逻辑正确,但还需要证明它穿越真实插件边界有效。Live QA(live-senpi-qa.md)使用真实senpiCLI、重建的工作区扩展与本地 mock provider,在一次性隔离环境中执行:
QA_HOME=$(mktemp -d -t omo-team-create-qa-home.XXXXXX) HOME="$QA_HOME" \ TEAM_E2E_OUT_DIR="$PWD/.omo/evidence/20260804-team-create-recovery/live-team-e2e-isolated-home" \ SENPI_BIN="$(command -v senpi)" \ node packages/omo-senpi/scripts/qa/team-e2e.mjs rm -rf "$QA_HOME"mock 被设计为只发一次team_create调用,参数中故意同时包含team_name: "stale-named-team"与名为e2eteam的完整inline_spec。观察到的结果(verdict.json):
- 总体判定:
PASS dual_field_calls: 1(恰好一次过度指定调用)invalid_argument_results: 0(不再有任何非法参数结果)- 唯一一次
team_create结果为details.kind: "created",团队e2eteam创建成功并有两个运行中的成员 - 两个成员都解析到本地
omo-mock/mock-1模型(全程未调用任何网络模型 API) - 团队生命周期、邮箱注入、崩溃恢复、恰好一次投递等全部检查通过
credentialIsolationClean: true、wholeDirUnchanged: true、leakedPids: 0
同时,隔离环境的处理也值得注意:首次 live QA 尝试继承了调用方的HOME,从而带入了用户真实的模型路由;最终证据使用一次性 HOME重跑并通过了全部检查,此前的诊断性失败被保留但未计入结果。这保证了验证过程既不泄露凭据、也不污染真实 Senpi 代理目录。
验证门禁与仓库级检查
verification-gates.txt 记录了完整的门禁清单,全部通过:
| 门禁 | 命令 | 结果 |
|---|---|---|
| 包级测试 | bun run --cwd packages/senpi-task test | exit 0 |
| 团队服务测试 | bun test ./packages/omo-senpi/src/components/task/team-service.test.ts | 7 pass / 0 fail / 19 个 expect |
| QA 脚本自检 | node packages/omo-senpi/scripts/qa/team-e2e.mjs --self-test | SELF-TEST OK |
| 仓库级测试 | bun run test:senpi | exit 0 / 0 fail |
| 类型检查 | bun run typecheck:packages | exit 0 |
| 构建 | bun run build | exit 0,全部步骤完成 |
此外,lsp-diagnostics.txt记录了变更文件的共享 LSP 守护进程诊断:所有改动的 TypeScript 文件零问题——修复没有引入any、抑制或非安全类型转换。
变更范围与历史脉络
提交范围控制
修复的提交集被严格限制在:
- Senpi 团队生命周期(
runTeamCreate及相关工具定义); - 聚焦回归测试(
lifecycle-precedence.test.ts); - live QA 脚本输入;
- 重新生成的 lead 扩展。
仅用于构建的 Codex/安装产物被显式排除在外,符合「最小正确行为变更」的自审结论。
相关历史提交
诊断记录给出了三条相关历史:
0be02d59f389:引入 Senpiteam_create的运行时 XOR 校验(即本修复替换掉的那个互斥拒绝逻辑);1b580615ad0e:移除模型提供的 lead 会话覆盖;a8654d385a7d:为inline_spec增加 JSON 字符串形态支持(本修复中coerceInlineSpec沿用了该能力)。
残留风险(自审明确承认)
- 其他面向模型的工具仍然使用「仅运行时互斥参数」模式。本次修复有意不将策略泛化到
team_create之外,避免超出报告故障范围的过度设计(见 self-review.md); - 首次 live QA 因继承调用方 HOME 而受用户模型路由影响,最终证据用一次性 HOME 重跑通过。
同类实现的对照:omo-opencode 侧仍保留严格互斥
值得注意的是,同一team_create能力在 omo-opencode 侧(lifecycle-inline-spec.ts)仍采用严格的恰好一个约束:
export const TeamCreateArgsSchema = z.preprocess(omitEmptyStringArgs, z.object({ teamName: z.string().min(1).nullish(), inline_spec: z.unknown().nullish(), leadSessionId: z.string().nullish(), }).superRefine((value, ctx) => { const optionCount = Number(value.teamName != null) + Number(value.inline_spec != null) if (optionCount !== 1) { ctx.addIssue({ code: "custom", message: "Provide exactly one of teamName or inline_spec." }) } }))其中omitEmptyStringArgs会先剔除空字符串参数,避免teamName: ""这类「看似存在、实则为空」的误判;parseInlineTeamSpec支持对象与 JSON 字符串两种输入,并通过normalizeTeamSpecInput+TeamSpecSchema+validateSpec完成归一化、解析与校验。两处实现共享同一业务目标(创建多智能体团队),但对「互斥失败如何兜底」选择了不同策略:Senpi 路径用优先级吸收过度指定,omo-opencode 路径用 schema 校验拒绝。这种差异也解释了为什么诊断中特别强调「本次修复不泛化到其他工具」——不同调用路径的 provider 兼容性归一化行为不同,修复策略必须逐路径评估。
经验总结
- schema 互斥约束在兼容性归一化后会失真:当 provider 适配层会展平根级对象联合时,
oneOf/anyOf不能作为跨路径的可靠互斥手段——必须先验证归一化行为,再决定是否依赖 schema 语义; - 模型复述规则不等于模型遵守规则:主会话中模型在推理文本里正确复述了 XOR 规则,输出却依然双字段。散文约束对某些模型路径(尤其 Moonshot 兼容通道)的约束力不可高估;
- 把「拒绝」改成「优先级」是更稳的兜底:当两个参数中一个信息量明显更丰富(
inline_spec携带完整团队定义)时,让丰富者获胜、直接执行,比让模型反复纠错更能收敛; - 回归测试要复现真实载荷:RED 测试特意构造了与生产故障完全一致的「
team_name+ 完整inline_spec」双字段载荷,并断言服务实际收到的参数形状(只含inlineSpec),既证明不报错,也证明语义正确; - 隔离验证防串扰:live QA 使用一次性 HOME、本地 mock provider、凭据隔离与目录摘要不变校验,保证验证过程零副作用、可复现。
【免费下载链接】oh-my-openagentOmO: Just type "mass ulw" keyword with your prompt. Now you are the master of graph engineering.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考