news 2026/9/18 21:05:39

深度解析 `team_create` 双参冲突死循环:基于 Oh My OpenAgent 的 inline_spec 优先级修复实证

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深度解析 `team_create` 双参冲突死循环:基于 Oh My OpenAgent 的 inline_spec 优先级修复实证

深度解析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_nameinline_spec两个互斥参数,导致工具反复返回invalid_arguments,即使模型已正确理解并复述互斥规则,仍然持续重试直至死循环。文章将还原故障现场、分析根因、讲解inline_spec优先级修复方案及其验证过程。

故障现场还原

主会话:同样的错误重复七次

在原始会话019fcb34-df92-78b4-af23-747951793586中,模型(kimi-k3-ultrafast-unlocked,走openai-completions兼容通道)对team_create工具连续发起七次调用:

调用参数形态结果
team_create:23team_name+inline_specinvalid_arguments
team_create:24team_name命名规格未找到
team_create:25team_name+inline_specinvalid_arguments
team_create:26team_name+inline_specinvalid_arguments
team_create:27team_name+inline_specinvalid_arguments
team_create:28team_name+inline_specinvalid_arguments
team_create:29team_name+inline_specinvalid_arguments
team_create:30team_name+inline_specinvalid_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/teamsomo.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_argumentsisError置为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 } }

关键变化点:

  1. 双字段时以inline_spec为准hasInline ? { inlineSpec } : { teamName: params.team_name },服务调用只携带内联规格;
  2. invalid_arguments保留两种场景:两个字段都没有、或inline_spec是非法 JSON 字符串(coerceInlineSpec负责把 JSON 字符串自动解析为对象);
  3. 错误分类不变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/agentagentsubagent_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: truewholeDirUnchanged: trueleakedPids: 0

同时,隔离环境的处理也值得注意:首次 live QA 尝试继承了调用方的HOME,从而带入了用户真实的模型路由;最终证据使用一次性 HOME重跑并通过了全部检查,此前的诊断性失败被保留但未计入结果。这保证了验证过程既不泄露凭据、也不污染真实 Senpi 代理目录。

验证门禁与仓库级检查

verification-gates.txt 记录了完整的门禁清单,全部通过:

门禁命令结果
包级测试bun run --cwd packages/senpi-task testexit 0
团队服务测试bun test ./packages/omo-senpi/src/components/task/team-service.test.ts7 pass / 0 fail / 19 个 expect
QA 脚本自检node packages/omo-senpi/scripts/qa/team-e2e.mjs --self-testSELF-TEST OK
仓库级测试bun run test:senpiexit 0 / 0 fail
类型检查bun run typecheck:packagesexit 0
构建bun run buildexit 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 兼容性归一化行为不同,修复策略必须逐路径评估。

经验总结

  1. schema 互斥约束在兼容性归一化后会失真:当 provider 适配层会展平根级对象联合时,oneOf/anyOf不能作为跨路径的可靠互斥手段——必须先验证归一化行为,再决定是否依赖 schema 语义;
  2. 模型复述规则不等于模型遵守规则:主会话中模型在推理文本里正确复述了 XOR 规则,输出却依然双字段。散文约束对某些模型路径(尤其 Moonshot 兼容通道)的约束力不可高估;
  3. 把「拒绝」改成「优先级」是更稳的兜底:当两个参数中一个信息量明显更丰富(inline_spec携带完整团队定义)时,让丰富者获胜、直接执行,比让模型反复纠错更能收敛;
  4. 回归测试要复现真实载荷:RED 测试特意构造了与生产故障完全一致的「team_name+ 完整inline_spec」双字段载荷,并断言服务实际收到的参数形状(只含inlineSpec),既证明不报错,也证明语义正确;
  5. 隔离验证防串扰: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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 21:05:30

洪水叙事的考古学真相:从泥板蓝图到文学基因链

1. 洪水叙事的考古学真相&#xff1a;从泥板蓝图到文学基因链 当一块8英寸乘3英寸的巴比伦泥板在2010年被欧文芬克尔&#xff08;Irving Finkel&#xff09;于大英博物馆库房中重新辨识出来时&#xff0c;它所承载的并非神话传说&#xff0c;而是一份距今3700年的工程指令——一…

作者头像 李华
网站建设 2026/9/18 21:05:04

Stata处理CNRDS绿色专利数据:从导入清洗到合并的完整实操指南

最近总有师弟师妹来问我同一个问题&#xff1a;在Stata里处理CNRDS绿色专利数据库时&#xff0c;为什么下载下来直接打开就乱码&#xff0c;为什么统计出来的绿色专利申请量跟论文里对不上&#xff0c;为什么merge之后样本翻了好几倍。我意识到&#xff0c;这个数据库虽然下载门…

作者头像 李华
网站建设 2026/9/18 21:03:23

RoboMaster电控实战:卡尔曼滤波在云台姿态与底盘估计中的落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 20:59:06

MySQL Explain执行计划详解:慢查询排查与索引优化实战

凌晨两点被电话叫起来&#xff0c;一条订单查询接口的 P99 从 80ms 直接冲到 4.2s&#xff0c;业务方在群里刷屏。我做的第一件事不是翻代码&#xff0c;而是连上库&#xff0c;把那条 SQL 原封不动复制出来&#xff0c;前面加上 EXPLAIN 敲回车。两秒钟后我看到 typeALL、rows…

作者头像 李华
网站建设 2026/9/18 20:57:22

IDEA打包Web项目war包的完整指南与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华