From 382307807aba1e63702ff3126ee68c3e9a8a0325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=B9=E5=9D=A4?= Date: Wed, 23 Sep 2026 09:40:29 +0800 Subject: [PATCH 1/3] feat(sdk): add multi-agent coordinator support for Qoder and Bailian - multiagent.agents accepts project logical names plus explicit external { agent_id } Managed Agent references across all four providers - Qoder Managed/Forward map roster members to id/template_id wire refs; Bailian managed rosters carry agent ids without member versions - provider-specific clear semantics (multiagent: null on Qoder, empty coordinator roster on Bailian) and semantic drift comparison that ignores platform-managed member fields - fail-closed sync/export reverse mapping to logical names or stable external references; sync collects all exports before writing yaml - deterministic topology validation: no self/nesting/cycles, 20-member cap, Qoder delivery matching, Forward rejects external agent ids - add live probes for Qoder Managed, Qoder Forward and Bailian, plus examples, docs and changeset --- .changeset/multi-agent-qoder-bailian.md | 7 + README.md | 2 +- README.zh-CN.md | 2 +- ...l-multiagent-member-implementation-plan.md | 67 +++ .../multi-agent-implementation-plan.md | 545 +++++++++++++++++ docs/concepts/resources.md | 2 +- docs/examples.md | 4 +- docs/guides/configure-an-agent.md | 12 +- docs/guides/configure-an-agent.zh-CN.md | 16 +- docs/guides/deploy-to-bailian.md | 43 +- docs/guides/deploy-to-qoder.md | 39 +- docs/reference/configuration.md | 49 +- .../multi-agent-live-probe-results.md | 165 ++++++ .../multi-agent-qoder-bailian-research.md | 257 ++++++++ docs/reference/providers.md | 6 +- docs/reference/providers.zh-CN.md | 10 +- examples/README.md | 5 +- examples/bailian/multiagent/agents.yaml | 80 +++ examples/qoder/multiagent-forward/agents.yaml | 73 +++ examples/qoder/multiagent/agents.yaml | 69 +++ .../sdk/src/internal/core/agent-runtime.ts | 1 + .../sdk/src/internal/core/destroy-runtime.ts | 57 +- .../sdk/src/internal/core/validate-config.ts | 43 +- .../sdk/src/internal/executor/executor.ts | 2 +- .../sdk/src/internal/executor/resolver.ts | 97 +-- packages/sdk/src/internal/graph/dependency.ts | 1 + .../sdk/src/internal/multiagent/comparable.ts | 58 ++ packages/sdk/src/internal/multiagent/model.ts | 11 + .../src/internal/multiagent/reverse-index.ts | 113 ++++ .../sdk/src/internal/multiagent/topology.ts | 59 ++ packages/sdk/src/internal/parser/schema.ts | 24 +- .../sdk/src/internal/planner/comparable.ts | 18 +- packages/sdk/src/internal/planner/refresh.ts | 8 +- .../sdk/src/internal/providers/ark/adapter.ts | 19 +- .../sdk/src/internal/providers/ark/mapper.ts | 25 +- .../src/internal/providers/bailian/adapter.ts | 32 +- .../providers/bailian/capabilities.ts | 6 +- .../src/internal/providers/bailian/mapper.ts | 22 +- .../src/internal/providers/claude/adapter.ts | 19 +- .../src/internal/providers/claude/mapper.ts | 20 +- .../sdk/src/internal/providers/interface.ts | 22 +- .../src/internal/providers/qoder/adapter.ts | 58 +- .../internal/providers/qoder/capabilities.ts | 6 +- .../src/internal/providers/qoder/mapper.ts | 37 +- .../internal/providers/resource-workflow.ts | 7 +- packages/sdk/src/internal/providers/shared.ts | 15 +- packages/sdk/src/internal/types/config.ts | 5 +- packages/sdk/tests/e2e/multiagent-live.ts | 550 ++++++++++++++++++ packages/sdk/tests/unit/ark-provider.test.ts | 31 + .../sdk/tests/unit/bailian-examples.test.ts | 26 + packages/sdk/tests/unit/bailian.test.ts | 60 ++ .../sdk/tests/unit/destroy-runtime.test.ts | 27 + .../sdk/tests/unit/map-deployment.test.ts | 29 +- .../tests/unit/multiagent-comparable.test.ts | 226 +++++++ .../tests/unit/multiagent-resolver.test.ts | 174 ++++++ .../unit/multiagent-reverse-index.test.ts | 122 ++++ .../tests/unit/multiagent-topology.test.ts | 278 +++++++++ .../tests/unit/provider-conformance.test.ts | 7 + .../sdk/tests/unit/provider-shared.test.ts | 105 ++++ .../sdk/tests/unit/qoder-examples.test.ts | 145 ++++- .../tests/unit/qoder-forward-template.test.ts | 57 ++ packages/sdk/tests/unit/slim-state.test.ts | 1 - 62 files changed, 3914 insertions(+), 162 deletions(-) create mode 100644 .changeset/multi-agent-qoder-bailian.md create mode 100644 docs/architecture/external-multiagent-member-implementation-plan.md create mode 100644 docs/architecture/multi-agent-implementation-plan.md create mode 100644 docs/reference/multi-agent-live-probe-results.md create mode 100644 docs/reference/multi-agent-qoder-bailian-research.md create mode 100644 examples/bailian/multiagent/agents.yaml create mode 100644 examples/qoder/multiagent-forward/agents.yaml create mode 100644 examples/qoder/multiagent/agents.yaml create mode 100644 packages/sdk/src/internal/multiagent/comparable.ts create mode 100644 packages/sdk/src/internal/multiagent/model.ts create mode 100644 packages/sdk/src/internal/multiagent/reverse-index.ts create mode 100644 packages/sdk/src/internal/multiagent/topology.ts create mode 100644 packages/sdk/tests/e2e/multiagent-live.ts create mode 100644 packages/sdk/tests/unit/multiagent-comparable.test.ts create mode 100644 packages/sdk/tests/unit/multiagent-resolver.test.ts create mode 100644 packages/sdk/tests/unit/multiagent-reverse-index.test.ts create mode 100644 packages/sdk/tests/unit/multiagent-topology.test.ts diff --git a/.changeset/multi-agent-qoder-bailian.md b/.changeset/multi-agent-qoder-bailian.md new file mode 100644 index 0000000..93c9bd1 --- /dev/null +++ b/.changeset/multi-agent-qoder-bailian.md @@ -0,0 +1,7 @@ +--- +"@openagentpack/sdk": minor +"@openagentpack/cli": minor +"@openagentpack/playground": minor +--- + +Add Multi-Agent coordinator support for Qoder (Managed Agents and Forward Templates) and Bailian Managed Agents. `multiagent.agents` accepts project logical names plus explicit external Managed Agent references (`{ agent_id }`), validates the phase-1 topology (no self/nesting/cycles, max 20 members, Qoder members share the coordinator's delivery type), maps members to `id` or `template_id` wire references, clears rosters via `multiagent: null` or an empty array, compares multi-agent drift semantically, and exports remote coordinators back to logical names or stable external references. External members are not lifecycle-managed, and Qoder Forward rejects `{ agent_id }` because its roster requires Template ids. diff --git a/README.md b/README.md index d1d3c1e..dc5306c 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ Beta testers can install `@openagentpack/cli@beta`; see the [release guide](./do | Agent | native | native | native | native | | MCP Server | native | native | native | native | | Memory Store | unsupported | native | native | native | -| Multi-Agent | unsupported | unsupported | native | native | +| Multi-Agent | native | native | native | native | | Deployment | native | native | native | emulated | | Session | native | native | native | native | diff --git a/README.zh-CN.md b/README.zh-CN.md index d77faf6..5cdad02 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -149,7 +149,7 @@ Beta 用户可以安装 `@openagentpack/cli@beta`;固定版本及切回稳定 | Agent | native | native | native | native | | MCP Server | native | native | native | native | | Memory Store | unsupported | native | native | native | -| Multi-Agent | unsupported | unsupported | native | native | +| Multi-Agent | native | native | native | native | | Deployment | native | native | native | emulated | | Session | native | native | native | native | diff --git a/docs/architecture/external-multiagent-member-implementation-plan.md b/docs/architecture/external-multiagent-member-implementation-plan.md new file mode 100644 index 0000000..a957906 --- /dev/null +++ b/docs/architecture/external-multiagent-member-implementation-plan.md @@ -0,0 +1,67 @@ +# Multi-Agent 外部成员引用实施计划 + +## 背景与目标 + +OpenAgentBot 的一个 Bot 会对应一个独立的 Managed Agent。聊天室把多个既有 Bot 拉入同一个协作空间时,coordinator 必须能够引用并非由当前 OpenAgentPack 项目创建的 Managed Agent。 + +本阶段在现有同项目逻辑名引用之外,增加显式的远端 Agent 引用: + +```yaml +multiagent: + type: coordinator + agents: + - researcher + - agent_id: agent_external_writer +``` + +配置 Interface 为: + +```ts +type MultiagentMemberDecl = string | { agent_id: string }; +``` + +- `string` 表示当前项目拥有的 Agent,参与依赖排序和生命周期管理; +- `{ agent_id }` 表示外部 Managed Agent,只作为 coordinator roster 的引用存在; +- 两种声明最终都在 resolver seam 后变成 Provider mapper 所需的远端 ID。 + +## 生命周期与所有权 + +外部成员不是当前项目的资源: + +- 不要求存在于 `agents.state.json`; +- 不进入创建、更新或销毁依赖图; +- `apply` 只会更新 coordinator 的 roster,不会修改外部 Agent; +- `destroy` 不会删除或归档外部 Agent; +- drift comparison 继续按解析后的远端 ID 比较。 + +`agents sync` 根据完整的 `/agents` 列表恢复声明:当前项目拥有且可唯一命名的成员恢复为逻辑名;其他项目拥有或无 OpenAgentPack metadata 的成员恢复为 `{ agent_id }`。远端列表中完全不存在的 ID 仍然 fail closed;当前项目拥有但已归档、缺少逻辑名或逻辑名冲突的成员也继续 fail closed。 + +## Provider 范围 + +| Provider / delivery | `{ agent_id }` | +| --- | --- | +| Qoder Managed | 支持 | +| Bailian Managed | 支持 | +| Qoder Forward | 拒绝;Forward roster 需要 `template_id`,本阶段不引入外部 Template 语法 | +| Claude / Ark | 公共声明可透传为 Managed Agent ID,不新增 Provider 特有语义 | + +## 实施顺序 + +1. 扩展公共类型和 Zod schema,校验空 ID 与重复外部 ID; +2. 让引用校验、拓扑、依赖图、运行时 readiness 和 destroy 仅遍历逻辑名成员; +3. resolver 直接接受外部 Agent ID,并对 Qoder Forward fail closed; +4. reverse/export 将非本项目成员稳定恢复为 `{ agent_id }`; +5. 更新配置参考、使用指南和 changeset; +6. 运行 multiagent/provider 定向测试、SDK 类型检查和 changed-files lint。 + +## 验收条件 + +- Managed coordinator 可混合引用本项目逻辑名和外部 `agent_id`; +- 外部成员不需要 state,且不会形成资源生命周期依赖; +- Qoder Forward 对 `{ agent_id }` 给出稳定、可定位的诊断; +- sync/apply/sync 保持外部 ID,不把它误认成本项目逻辑名; +- 原有纯逻辑名 roster 行为与 Provider wire payload 不回归。 + +## 非目标 + +本阶段不支持 `self`、Advisor、显式成员版本、外部 Forward Template(`template_id`)、跨项目资源导入或外部 Agent 的存在性/权限预检。 diff --git a/docs/architecture/multi-agent-implementation-plan.md b/docs/architecture/multi-agent-implementation-plan.md new file mode 100644 index 0000000..d15d718 --- /dev/null +++ b/docs/architecture/multi-agent-implementation-plan.md @@ -0,0 +1,545 @@ +# Qoder 与百炼 Multi-Agent 实施计划 + +> 状态:Ready for implementation +> 目标读者:接手实现的编码 Agent +> 调研基线:2026-09-22 + +> 本文记录最初的项目内逻辑名 Phase 1 基线。后续加入的外部 Managed Agent 引用以 +> [Multi-Agent 外部成员引用实施计划](./external-multiagent-member-implementation-plan.md) 为准;其中关于 +> “外部成员不支持”和 `unowned` 必须失败的旧约束已被该增量设计取代。 + +## 目标 + +让 OpenAgentPack 现有公共配置: + +```yaml +multiagent: + type: coordinator + agents: [researcher, reviewer] +``` + +在以下三种远端 materialization 中可预测地工作: + +1. Qoder Managed Agent; +2. Qoder Forward Template; +3. 阿里云百炼 Managed Agent。 + +完成后必须支持:配置校验、依赖排序、引用解析、创建、更新、解除编队、drift 比较、Qoder/Bailian Managed sync/export、文档、示例和自动化测试。 + +## 开工前必读 + +按顺序完整阅读: + +1. [能力调研与真机证据](../reference/multi-agent-qoder-bailian-research.md)。平台字段、更新语义和限制以该文档为准,不重新猜测。 +2. [Provider 开发指南](../contributing/provider-development.md)。 +3. [配置参考](../reference/configuration.md)中的 Multi-Agent 配置。 +4. 现有 Claude 与 Ark 实现: + - `packages/sdk/src/internal/providers/claude/mapper.ts` + - `packages/sdk/src/internal/providers/ark/mapper.ts` + - `examples/claude/multiagent/agents.yaml` + - `examples/ark/multiagent/agents.yaml` +5. 当前引用和生命周期代码: + - `packages/sdk/src/internal/core/validate-config.ts` + - `packages/sdk/src/internal/graph/dependency.ts` + - `packages/sdk/src/internal/executor/resolver.ts` + - `packages/sdk/src/internal/providers/interface.ts` + - `packages/sdk/src/internal/providers/resource-workflow.ts` + - `packages/sdk/src/internal/planner/comparable.ts` + - `packages/sdk/src/internal/planner/refresh.ts` + - `packages/sdk/src/internal/providers/shared.ts` + +官方一手资料: + +- [Qoder Multi-Agent 总览](https://docs.qoder.com/cloud-agents/multi-agents) +- [Qoder Managed Agent 创建](https://docs.qoder.com/cloud-agents/api/agents/create) +- [Qoder Managed Agent 更新](https://docs.qoder.com/cloud-agents/api/agents/update) +- [Qoder Forward Template 创建](https://docs.qoder.com/cloud-agents/api/forward/templates/create) +- [Qoder Forward Template 更新](https://docs.qoder.com/cloud-agents/api/forward/templates/update) +- [百炼多智能体协作](https://docs.agent.bailian.aliyun.com/zh/managed-agents/build-agent/multiagent) +- [百炼创建 Agent](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/agent/create) +- [百炼更新 Agent](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/agent/update) + +## 已冻结的产品决策 + +实现过程中不得自行改变这些决策。 + +### 第一阶段范围 + +包含: + +- `type: coordinator`; +- `agents: string[]` 中的项目内逻辑 Agent 名称; +- Qoder Managed、Qoder Forward、百炼; +- 1–20 个普通成员; +- create/update/clear; +- plan/apply/destroy 的依赖顺序; +- drift; +- Qoder Managed 与百炼 Managed 的 sync/export; +- provider capability、文档和示例。 + +不包含: + +- `self`; +- Qoder Advisor; +- 用户显式选择成员数字版本或 `latest`; +- Session Thread list/get/archive/events/interrupt 接口; +- Qoder Forward Template 的 sync/export;当前 `exportResources` 只覆盖 Managed `/agents`,本次不要扩成完整 Forward import 系统; +- 外部/跨项目 Agent ID 引用; +- 嵌套 coordinator。 + +### 引用与所有权 + +- YAML 中只接受项目逻辑名称,不接受远端 ID。 +- state 中的 `(provider, resource_type, logical_name) -> remote_id` 是 apply/drift 的引用权威。 +- sync/export 的反向映射首先使用远端 `agents.resource` ownership metadata;名称只用于展示,不作为身份。 +- 无法反向映射时 fail closed:报告诊断并保留现有本地声明,不输出远端 ID,不静默删除 roster。 + +### 拓扑 + +- coordinator 不得引用自身;沿用现有 `config.agent.multiagent.self`。 +- coordinator 不得引用另一个声明了 `multiagent` 的 Agent。新增 `config.agent.multiagent.nested`。 +- 所有直接或间接循环必须在本地校验阶段拒绝。新增 `config.agent.multiagent.cycle`,错误信息必须包含完整循环路径,例如 `lead -> reviewer -> lead`。 +- 远端平台是否接受循环与本地规则无关。Qoder Managed/Forward 真机会接受循环;百炼会拒绝循环,但不能依赖远端兜底。 + +### 版本语义 + +字符串成员引用使用各 Provider 的原生默认语义: + +- Qoder Managed:提交时省略 `version`,平台在 coordinator 保存时固定成员当前数字版本; +- Qoder Forward:使用 `template_id`,没有成员版本字段; +- 百炼:提交时省略 `version`,child Thread 首次创建时解析最新版本,并在该 Thread 生命周期内固定。 + +第一阶段不尝试抹平上述差异。配置文档必须明确它们。 + +### 更新与清除 + +- Qoder Managed create/update roster:`{type:"coordinator",agents:[{type:"agent",id}]}`。 +- Qoder Forward create/update roster:`{type:"coordinator",agents:[{type:"agent",template_id}]}`。 +- 百炼 create/update roster:`{type:"coordinator",agents:[{type:"agent",id}]}`。 +- Qoder Managed/Forward 为 merge update;本地删除 `multiagent` 时更新请求必须显式发送 `multiagent: null`。 +- 百炼为全量替换;本地删除 `multiagent` 时更新请求发送 `{type:"coordinator",agents:[]}`,远端读取会归一为 `null`。 +- create 请求在未声明 `multiagent` 时省略字段。 + +### Drift + +- 本地字符串逻辑名和远端对象引用必须先转为同一种 canonical comparable,再比较。 +- 成员身份按远端 ID比较;Qoder Forward 的成员 `name` 不参与比较。 +- Qoder 自动回填的数字版本不产生永久 drift;第一阶段将其视为平台保存快照,而不是用户声明。 +- 百炼 `version:null` 与本地省略版本等价。 +- roster 按配置顺序保存,但 drift 比较按成员身份集合比较。当前没有平台契约证明 roster 顺序影响路由。 + +## 目标内部模型 + +在 `packages/sdk/src/internal/multiagent/` 新建纯逻辑模块。推荐文件: + +```text +packages/sdk/src/internal/multiagent/ +├── model.ts +├── topology.ts +├── comparable.ts +└── reverse-index.ts +``` + +使用以下内部类型;名字可以按仓库惯例微调,语义不得改变: + +```ts +export interface ResolvedMultiagentMember { + logical_name: string; + resource_type: "agent" | "template"; + remote_id: string; +} + +export interface ResolvedMultiagentRoster { + type: "coordinator"; + members: ResolvedMultiagentMember[]; +} + +export interface ComparableMultiagentRoster { + type: "coordinator"; + member_ids: string[]; // sorted, unique +} +``` + +`ResolvedAgentRefs` 不再只携带裸 `multiagent_agent_ids?: string[]`。替换为能保留逻辑名和资源类型的 roster: + +```ts +multiagent?: ResolvedMultiagentRoster; +``` + +这是内部破坏性修改;一次性更新 Claude、Ark、Qoder、Bailian mapper 和相关测试,不保留双字段兼容层。 + +## 执行步骤 + +每一步都必须达到完成条件后再继续。 + +### 1. 先锁定测试矩阵 + +新增或扩展以下测试: + +- `packages/sdk/tests/unit/multiagent-topology.test.ts` +- `packages/sdk/tests/unit/multiagent-comparable.test.ts` +- `packages/sdk/tests/unit/resolve-from-object.test.ts` 或新建 `multiagent-resolver.test.ts` +- `packages/sdk/tests/unit/qoder-examples.test.ts` +- `packages/sdk/tests/unit/qoder-forward-template.test.ts` +- `packages/sdk/tests/unit/bailian.test.ts` +- `packages/sdk/tests/unit/ark-provider.test.ts` +- Claude mapper 对应的现有测试;用 `rg "mapAgent" packages/sdk/tests` 定位,不另造重复测试文件。 +- `packages/sdk/tests/unit/provider-conformance.test.ts` +- 必要时扩展 `packages/sdk/tests/unit/drift-detection.test.ts` 与 `executor-drift-baseline.test.ts`。 +- Qoder/Bailian Adapter HTTP 路由契约继续放在现有 `packages/sdk/tests/e2e/*adapter*.test.ts`,这些是 mock HTTP 测试,不是真机测试。 + +首先写出失败测试,至少覆盖: + +1. 未知成员; +2. 自引用; +3. coordinator 引用 coordinator; +4. 两节点和三节点循环; +5. 同成员重复出现; +6. 0 个和 21 个成员; +7. Managed 引用解析为 `agent` ID; +8. Forward 引用解析为 `template` ID; +9. 缺失远端 ID时抛出 `UserError`,不跳过; +10. 三种 Provider payload; +11. 三种 clear payload; +12. Qoder toolset 恰好出现一次; +13. Qoder 自动版本和百炼 `null` 版本不产生 drift; +14. reverse mapping 成功、unresolved、archived、unowned; +15. 依赖顺序为成员先于 coordinator,销毁顺序相反。 + +完成条件:新增测试能够编译,且因为目标行为尚未实现而失败;失败原因与预期行为一致。 + +### 2. 收紧公共 Schema 与配置校验 + +修改: + +- `packages/sdk/src/internal/parser/schema.ts` +- `packages/sdk/src/internal/core/validate-config.ts` + +规则: + +- `multiagent.agents` 在公共 parser 使用 `.min(1)`;Qoder/Bailian 的 20 成员上限放在 provider-aware validation,避免把两家的当前限制错误施加给其他 Provider; +- 同一 roster 内名称唯一; +- 保留 unknown/self 检查; +- 增加 nested 检查; +- 对全部 Agent 构造有向图并检测循环,输出完整循环路径; +- 删除 `qoder.template.multiagent.unsupported`;Forward 已由官方与真机确认支持; +- capability 校验只在实际 materialization 支持时放行。Qoder Managed/Forward、百炼 Managed 均放行。 + +循环检测放进 `internal/multiagent/topology.ts`,`validate-config.ts` 只负责把诊断写入现有 diagnostics 接口。 + +完成条件:拓扑测试全部通过;错误 code 和错误消息稳定,不依赖对象遍历偶然顺序。 + +### 3. 建立 materialization-aware 引用解析 + +修改: + +- `packages/sdk/src/internal/providers/interface.ts` +- `packages/sdk/src/internal/executor/resolver.ts` +- 使用现有 `resolveAgentMaterialization`,不要复制 delivery 判断。 + +行为: + +- `resolveAgentRefs` 为 Managed coordinator 解析成员的 `agent` state 地址; +- `resolveTemplateRefs` 为 Forward coordinator 解析成员的 `template` state 地址; +- 每个声明成员必须通过 `requireRef`;删除当前 `if (id) push` 的静默跳过行为; +- roster 保留 `logical_name`、`resource_type`、`remote_id`; +- Claude、Ark 继续从新 roster 取 `remote_id`,保持现有 wire 格式。 + +不要让 `resolveTemplateRefs` 直接复用会解析 `agent` 地址的 Multi-Agent 部分。可提取共享的 Skill refs,再分别解析 Managed/Forward roster。 + +完成条件:Managed/Forward resolver 测试通过;任何缺失成员都会在发请求前失败。 + +### 4. 实现 Provider wire mapping + +#### Qoder Managed + +修改 `packages/sdk/src/internal/providers/qoder/mapper.ts`: + +- create/update body 有 roster 时输出: + + ```ts + { + type: "coordinator", + agents: refs.multiagent.members.map(({ remote_id }) => ({ + type: "agent", + id: remote_id, + })), + } + ``` + +- 保留现有唯一的 `agent_toolset_20260401`;不另加第二个 toolset; +- `agentToDecl` 暂时只在拿到 reverse index 时恢复逻辑名称,见步骤 7。 + +#### Qoder Forward + +修改 `mapForwardTemplate`: + +```ts +{ + type: "coordinator", + agents: refs.multiagent.members.map(({ remote_id }) => ({ + type: "agent", + template_id: remote_id, + })), +} +``` + +不要发送 Managed 的 `id` 或 `version`,不要依赖远端成员 `name`。 + +#### 百炼 + +修改 `packages/sdk/src/internal/providers/bailian/mapper.ts`: + +```ts +{ + type: "coordinator", + agents: refs.multiagent.members.map(({ remote_id }) => ({ + type: "agent", + id: remote_id, + })), +} +``` + +省略 `version`,保持真机已证实的 child Thread 首次创建时解析语义。 + +完成条件:所有 mapper 测试通过,三个 wire payload 与调研报告完全一致。 + +### 5. 分离 create 与 update 的 clear 语义 + +当前 mapper 同时服务 create/update,而“本地未声明”在 create 是省略、在 update 可能是清除。必须显式传入操作上下文,推荐: + +```ts +type MappingOperation = "create" | "update"; +``` + +将 operation 传入 `mapAgent` / `mapForwardTemplate`,或新增 `mapAgentUpdate` 包装函数。选择改动较小的一种,但必须满足: + +- create + no multiagent → 字段不存在; +- Qoder Managed update + no multiagent → `multiagent: null`; +- Qoder Forward update + no multiagent → `multiagent: null`; +- Bailian update + no multiagent → `{type:"coordinator",agents:[]}`; +- Bailian create + no multiagent → 字段不存在。 + +更新: + +- Qoder/Bailian Adapter 的 create/update 调用; +- `normalizeDesiredResource` 调用时使用 create-style canonical desired,不把 clear sentinel 当成最终状态; +- 所有 mapper 调用测试。 + +完成条件:clear 契约测试通过;从有 roster 更新到无 roster 后,下一次读取的 comparable 为无 roster。 + +### 6. 启用 capability 和依赖图 + +修改: + +- `packages/sdk/src/internal/providers/qoder/capabilities.ts` +- `packages/sdk/src/internal/providers/bailian/capabilities.ts` +- `packages/sdk/src/internal/graph/dependency.ts` +- `packages/sdk/tests/unit/provider-conformance.test.ts` + +设置: + +```ts +multiagent: { tier: "native", reason: "coordinator + roster topology" } +``` + +依赖图必须使用 `resolveAgentMaterialization(provider, subDecl)` 决定成员节点是 `agent` 还是 `template`。不要只根据 coordinator 自身的 materialization 假设成员类型;第一阶段要求同一 roster 的成员与 coordinator 在同一 Provider 下可解析为各自声明的 materialization,且 Qoder Forward coordinator 的成员必须是 Forward Template。若成员 materialization 不匹配,校验阶段产生: + +```text +qoder.template.multiagent.member_materialization +``` + +第一阶段的明确规则: + +- Qoder Managed coordinator 只能引用 Qoder Managed Agent; +- Qoder Forward coordinator 只能引用 Qoder Forward Template; +- 百炼只有 Managed Agent; +- Claude/Ark 保持现状。 + +完成条件:plan 创建层级先成员后 coordinator;destroy 反向;混合 materialization 在 plan 前失败。 + +### 7. 实现 semantic drift + +新增 `internal/multiagent/comparable.ts`,集中处理: + +```ts +canonicalizeDesiredRoster(resolvedRoster) +canonicalizeRemoteRoster(rawMultiagent, materialization) +``` + +规范: + +- 输出 `member_ids` 去重并排序; +- Managed 读取 `id`;Forward 读取 `template_id`; +- 忽略 Qoder Forward `name`; +- 忽略第一阶段不可声明的远端成员版本; +- `null`、缺失和空 roster 统一为 `undefined`; +- 遇到 `self`、Advisor 或未知成员类型时返回 unsupported diagnostic,不把它们静默当成普通 Agent。 + +现有 `normalizeDesiredResource(type,name,decl)` 拿不到 resolved refs。不要在 mapper 内通过名称猜远端 ID。扩展 drift seam,让 planner 在已有 `config + state + address` 的位置先调用 resolver,并把 resolved refs 传给 Provider 的 desired normalization。推荐将可选第四参数加入接口: + +```ts +normalizeDesiredResource( + type: ResourceType, + name: string, + decl: unknown, + refs?: ResolvedAgentRefs | ResolvedTemplateRefs, +): unknown | null; +``` + +调用方只对 `agent`/`template` 计算 refs,其他资源行为不变。同步更新 `resource-workflow.ts` 与所有 fake Adapter。 + +完成条件:连续两次 refresh/plan 不产生永久 Multi-Agent drift;远端成员被手工替换或删除时产生 drift。 + +### 8. 实现 Managed sync/export 的安全 reverse mapping + +修改: + +- `packages/sdk/src/internal/providers/shared.ts` +- Qoder/Bailian/Claude/Ark 的 `agentToDecl` 函数签名和调用; +- 对应 export/sync 测试。 + +为 `/agents` 完整清单建立一次 index: + +```ts +remote agent id -> { + logical_name, + archived, + owned, +} +``` + +`logical_name` 只从 metadata 中显式存在的 `agents.resource` 得到,并同时校验 `agents.project` 与当前项目相同。不要调用会回退到展示名称或 ID 的 helper,也不要使用 coordinator roster 中的 `name`。 + +扩展 `agentToDecl` 接收 resolver: + +```ts +agentToDecl(raw, resolveMemberName) +``` + +策略: + +- 全部成员可映射 → 输出字符串逻辑名 roster; +- 成员归档 → `sync.multiagent.member.archived`; +- 成员未归属本项目 → `sync.multiagent.member.unowned`; +- 远端 ID 不在清单或无法读取 → `sync.multiagent.member.unresolved`; +- 多个本地资源映射同一 ID → `sync.multiagent.member.ambiguous`。 + +当前 `ExportedResource` 没有 diagnostics 通道。不要吞错。采用仓库现有 sync 错误风格:若没有可复用的结构化诊断返回类型,抛出带上述稳定 code 文本的 `UserError`,使 sync 在写文件前终止。确认 `sync-runtime.ts` 先收集再写入;若不是原子写入,先修成“全部 export 成功后再写”。 + +Qoder Forward Template sync/export 保持非目标;不得从 Managed `/agents` 索引推断 Template。 + +完成条件:成功 reverse 后 YAML 只含逻辑名称;四类失败均不改写现有文件。 + +### 9. 文档与示例 + +新增: + +- `examples/qoder/multiagent/agents.yaml`:Managed 示例; +- `examples/qoder/multiagent-forward/agents.yaml`:Forward 示例; +- `examples/bailian/multiagent/agents.yaml`。 + +更新: + +- `examples/README.md` +- `docs/examples.md` +- `docs/concepts/resources.md` +- `docs/guides/configure-an-agent.md` +- `docs/guides/configure-an-agent.zh-CN.md` +- `docs/guides/deploy-to-qoder.md` +- `docs/guides/deploy-to-bailian.md` +- `docs/reference/configuration.md` +- `docs/reference/providers.md` +- `docs/reference/providers.zh-CN.md` +- 新增一条 `.changeset/*.md`,覆盖 SDK/CLI 用户可见的 Provider capability 变化;按仓库现有 changeset 格式选择版本级别。 + +文档必须说明: + +- 只支持项目内逻辑名称; +- 第一阶段禁止嵌套和循环; +- 三种 materialization 的版本语义; +- Qoder/百炼 clear 差异由 Adapter 隐藏; +- 百炼 child 可并行且共享文件系统,coordinator instructions 应声明文件所有权; +- `self`、Advisor、显式版本和 Thread 管理尚未由公共 Schema 暴露。 + +完成条件:示例通过现有 parser/validate 测试;中英文能力矩阵一致。 + +### 10. 验证与真机回归 + +本地验证按顺序运行: + +```bash +bun test packages/sdk/tests/unit/multiagent-topology.test.ts +bun test packages/sdk/tests/unit/multiagent-comparable.test.ts +bun test packages/sdk/tests/unit/qoder-examples.test.ts +bun test packages/sdk/tests/unit/qoder-forward-template.test.ts +bun test packages/sdk/tests/unit/bailian.test.ts +bun test packages/sdk/tests/unit/provider-conformance.test.ts +bun run typecheck:sdk +bun run lint:changed +bun run verify:scoped +git diff --check +``` + +如果测试文件最终名称不同,使用等价的新路径;不要跳过相应行为。 + +凭据存在时,新增一个显式 opt-in 的 live probe,默认测试套件不得调用生产 API。建议: + +```text +packages/sdk/tests/e2e/multiagent-live.ts +``` + +通过参数选择 `qoder-managed`、`qoder-forward`、`bailian`。要求: + +- 名称使用 `oap-ma-live-${timestamp}`; +- Qoder 临时资源最终 DELETE/archive; +- 百炼 Agent 最终 archive,Session/Environment 删除; +- `finally` 中清理; +- 日志不输出 token、完整用户 metadata 或非测试资源清单; +- 只验证一个 worker + coordinator 的创建、读取、运行、清除和清理;不要把研究阶段的循环测试放入日常 live probe。 + +完成条件:三个模式至少各人工执行一次并保存脱敏结果;所有临时资源已清理。 + +## 验收清单 + +实现只有在以下全部成立时完成: + +- [ ] Qoder Managed、Qoder Forward、百炼都能从同一公共 YAML 创建 coordinator。 +- [ ] 三者请求体字段正确:Managed/Bailian 用 `id`,Forward 用 `template_id`。 +- [ ] Qoder `agent_toolset_20260401` 恰好出现一次。 +- [ ] 删除本地 `multiagent` 后远端 roster 被解除。 +- [ ] 未知、缺失、嵌套和循环引用在网络请求前失败。 +- [ ] 成员创建先于 coordinator;销毁顺序相反。 +- [ ] 连续 plan 无永久 drift。 +- [ ] 远端手工替换成员会产生 drift。 +- [ ] Managed sync/export 输出逻辑名称,不输出远端 ID。 +- [ ] unresolved/archived/unowned/ambiguous reverse 均 fail closed 且不改文件。 +- [ ] Qoder/Bailian capability 不再显示 unsupported。 +- [ ] Claude/Ark 现有 Multi-Agent 测试无回归。 +- [ ] 文档、示例、中英文能力矩阵已更新。 +- [ ] scoped verification、typecheck、lint 和 diff check 全部通过。 +- [ ] live probe 的临时资源全部清理。 + +## 不得采用的捷径 + +- 直接把远端 ID 写入 `agents.yaml`; +- 在 resolver 找不到成员时跳过它; +- 用远端展示名称代替 ownership metadata; +- 用一个 Qoder payload 同时处理 Managed 与 Forward; +- 仅修改 capability 而不补 mapper、clear、drift 和 reverse; +- 让 Adapter 外的调用方知道 `null` 与空 roster 的 Provider 差异; +- 依赖 Qoder 或百炼服务端检测循环; +- 看到远端自动版本字段就永久报告 drift; +- 将真机循环/嵌套探针加入默认测试套件。 + +## 实施完成后的交付说明 + +最终回复必须包含: + +1. 支持矩阵:Qoder Managed、Qoder Forward、百炼分别实现了什么; +2. 公共 Schema 是否变化; +3. clear、版本和 reverse mapping 的实际语义; +4. 修改文件与新增示例; +5. 执行过的测试和结果; +6. live probe 是否运行以及资源清理结果; +7. 仍然明确排除的 `self`、Advisor、显式版本和 Thread 管理。 diff --git a/docs/concepts/resources.md b/docs/concepts/resources.md index c02ce56..0e94417 100644 --- a/docs/concepts/resources.md +++ b/docs/concepts/resources.md @@ -19,7 +19,7 @@ These are the top-level blocks you write in a config. Each maps to a state-track Two more facets are expressed *through* an agent rather than as standalone blocks: - **MCP server** — declared on `agents..mcp_servers[]`; an external tool server reached over the MCP protocol. -- **Multi-agent** — declared on `agents..multiagent`; one agent orchestrates others in `coordinator` mode *(Claude, Ark)*. +- **Multi-agent** — declared on `agents..multiagent`; one agent orchestrates others in `coordinator` mode. All four providers support it; on Qoder, members must share the coordinator's delivery type (Managed Agent or Forward Template). `session` is a runtime concept, not a declared resource — see [Sessions and deployments](./sessions-and-deployments.md). diff --git a/docs/examples.md b/docs/examples.md index 5a5102c..14efed1 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -15,7 +15,7 @@ The [`examples/`](../examples) directory has runnable configs for every provider | Connect a credential-based IM Channel | [`examples/qoder/with-channel/`](../examples/qoder/with-channel/) | | Use memory stores | [`examples/qoder/with-memory/`](../examples/qoder/with-memory/) · [`examples/claude/with-memory/`](../examples/claude/with-memory/) · [`examples/ark/full/`](../examples/ark/full/) · [runtime lifecycle](../examples/memory/README.md) | | Upload local files (Files API) | [`examples/bailian/with-files/`](../examples/bailian/with-files/) · [`examples/ark/with-files/`](../examples/ark/with-files/) | -| Coordinate multiple agents | [`examples/claude/multiagent/`](../examples/claude/multiagent/) · [`examples/ark/multiagent/`](../examples/ark/multiagent/) | +| Coordinate multiple agents | [`examples/bailian/multiagent/`](../examples/bailian/multiagent/) · [`examples/claude/multiagent/`](../examples/claude/multiagent/) · [`examples/qoder/multiagent/`](../examples/qoder/multiagent/) · [`examples/qoder/multiagent-forward/`](../examples/qoder/multiagent-forward/) · [`examples/ark/multiagent/`](../examples/ark/multiagent/) | | Deploy to multiple providers | [`examples/claude/multi-provider/`](../examples/claude/multi-provider/) · [`examples/qoder/multi-provider/`](../examples/qoder/multi-provider/) | | Schedule a deployment | [`examples/bailian/deployment/`](../examples/bailian/deployment/) · [`examples/claude/deployment/`](../examples/claude/deployment/) · [`examples/qoder/deployment/`](../examples/qoder/deployment/) · [`examples/ark/deployment/`](../examples/ark/deployment/) | | Run everything end-to-end | [`examples/bailian/full/`](../examples/bailian/full/) · [`examples/claude/full/`](../examples/claude/full/) · [`examples/qoder/full/`](../examples/qoder/full/) · [`examples/ark/full/`](../examples/ark/full/) | @@ -52,7 +52,7 @@ agents destroy | Agent | native | native | native | native | | MCP Server | native | native | native | native | | Memory Store | unsupported | native | native | native | -| Multi-Agent | unsupported | unsupported | native | native | +| Multi-Agent | native | native | native | native | | Deployment | native | native | native | emulated | | Session | native | native | native | native | diff --git a/docs/guides/configure-an-agent.md b/docs/guides/configure-an-agent.md index 8c8d756..902ca17 100644 --- a/docs/guides/configure-an-agent.md +++ b/docs/guides/configure-an-agent.md @@ -146,9 +146,9 @@ agents: memory_stores: [project-memory] ``` -## Multi-agent coordination (Claude, Ark) +## Multi-agent coordination -One agent can orchestrate others in `coordinator` mode: +One agent can orchestrate others in `coordinator` mode. All four providers support it: ```yaml agents: @@ -158,6 +158,14 @@ agents: agents: [researcher, reviewer] ``` +Rules: + +- `multiagent.agents` accepts project **logical names** and explicit external Managed Agent references such as `{ agent_id: agent_external_1 }`. External references are not lifecycle-managed by OpenAgentPack and are not supported by Qoder Forward delivery. +- Logical-name members must be declared in the same config; a coordinator cannot orchestrate itself, nest another coordinator, or form a cycle (direct or indirect). External `{ agent_id }` members are treated as non-owned leaves. Rosters are capped at 20 members. +- On Qoder, every member must use the same delivery type as its coordinator — all Managed Agents (`id` references) or all Forward Templates (`template_id` references). + +Examples: [`examples/claude/multiagent/`](../../examples/claude/multiagent/) · [`examples/qoder/multiagent/`](../../examples/qoder/multiagent/) · [`examples/qoder/multiagent-forward/`](../../examples/qoder/multiagent-forward/) · [`examples/bailian/multiagent/`](../../examples/bailian/multiagent/) · [`examples/ark/multiagent/`](../../examples/ark/multiagent/). + ## Deployments A **deployment** bundles an agent, its runtime bindings, initial events, and a schedule into a repeatable run unit managed by `plan`/`apply`: diff --git a/docs/guides/configure-an-agent.zh-CN.md b/docs/guides/configure-an-agent.zh-CN.md index bcffa7a..2756227 100644 --- a/docs/guides/configure-an-agent.zh-CN.md +++ b/docs/guides/configure-an-agent.zh-CN.md @@ -439,7 +439,7 @@ OpenCMA 删除,会继续挂载默认 Store,因此 `delete_on_destroy: true` 通过 coordinator 模式,一个 Agent 可以调度其他 Agent 协同完成任务。 -> 注意:Multi-Agent 目前由 Claude 和 火山方舟 Provider 原生支持。 +> 注意:Multi-Agent 由四家 Provider(百炼、Qoder、Claude、火山方舟)原生支持。 ```yaml agents: @@ -478,7 +478,19 @@ agents: agents: [researcher, writer] ``` -`multiagent.agents` 中引用的 Agent 必须在同一配置文件中定义。OpenAgentPack 会自动处理依赖顺序,先创建子 Agent,再创建 coordinator。 +`multiagent.agents` 可以写同一配置文件中的**项目逻辑名称**,也可以用 `{ agent_id: ... }` 显式引用外部 Managed Agent。OpenAgentPack 会为逻辑名成员处理依赖顺序和生命周期;外部成员不需要本地 state,且不会被 OpenAgentPack 创建、更新或销毁。Qoder Forward 暂不支持外部 `agent_id`,因为其 roster 需要 Template ID。 + +第一阶段拓扑限制: + +- coordinator 不能编排自己,不能嵌套另一个 coordinator,也不能形成任何直接或间接循环。 +- 成员名单上限为 20 个。 +- Qoder 上,所有成员必须与 coordinator 使用相同交付类型——全部为 Managed Agent(按 `id` 引用)或全部为 Forward Template(按 `template_id` 引用)。 + +各 Provider 的成员版本语义由 Adapter 统一处理,公共 YAML 不写版本号:Qoder Managed 在 coordinator 保存时由平台固定成员版本;Qoder Forward 本身没有版本字段;百炼省略版本号,由子 Thread 首次创建时解析最新成员并在 Thread 生命周期内固定。移除全部成员即清除编排关系(Qoder 发送 `multiagent: null`,百炼发送空名单),无需其他配置。 + +百炼的子 Agent 并行执行并共享 coordinator 的文件系统,建议在 coordinator 的 instructions 中声明每个成员的目录归属,避免相互覆盖文件。 + +`self`、Advisor、显式版本和 Thread 管理不在公共 Schema 中暴露。 --- diff --git a/docs/guides/deploy-to-bailian.md b/docs/guides/deploy-to-bailian.md index 32ac5ee..ffa14ac 100644 --- a/docs/guides/deploy-to-bailian.md +++ b/docs/guides/deploy-to-bailian.md @@ -1,6 +1,6 @@ # Deploy to Bailian -Bailian (Aliyun AgentStudio) manages agents with versioned updates and references **official MCP servers by name** rather than wiring vaults for them. +Bailian (Aliyun AgentStudio) manages agents with versioned updates, references **official MCP servers by name** rather than wiring vaults for them, and supports **multi-agent coordinators** whose children run in parallel. ## Provider configuration @@ -25,7 +25,7 @@ providers: |---------|:----:| | Environment, Vault, Skill, Agent, MCP Server, Session | native | | Memory Store | unsupported | -| Multi-Agent | unsupported | +| Multi-Agent | native | | Deployment | native | - Skills upload as a zip via the Files API (two-step). @@ -63,6 +63,45 @@ agents: builtin: [bash, read, glob, grep] ``` +## Multi-agent + +A Bailian coordinator delegates to other declared agents. Child agents run in **parallel over a shared file system** — files written by one member are visible to the others, so declare file/directory ownership in the coordinator's instructions: + +```yaml +agents: + researcher: + model: qwen3.7-max + instructions: | + Research the task. Only write files under /work/research/. + environment: dev + writer: + model: qwen3.7-max + instructions: | + Turn findings into reports. Only write files under /work/report/. + environment: dev + lead: + model: qwen3.7-max + instructions: | + You are the lead agent coordinating a team: + - researcher: owns /work/research/ + - writer: owns /work/report/ + Child agents run in parallel on one shared file system, so keep each + member inside its own directory. Delegate, then synthesize into /work/report/. + environment: dev + multiagent: + type: coordinator + agents: [researcher, writer] +``` + +Rules and behavior: + +- Roster entries are **project logical names**; OpenAgentPack resolves them to remote agent ids. Nesting, cycles, self references, and rosters over 20 members are rejected at plan time. +- Member versions are never declared: each child Thread resolves the latest member version when it is first created and keeps that version for its lifetime. +- Removing `multiagent` from the declaration clears the remote roster on update — the adapter sends Bailian's empty roster for you. +- `self` references, explicit member versions, and Thread management are not exposed by the common schema yet. + +See [`examples/bailian/multiagent/`](../../examples/bailian/multiagent/). + ## What Bailian uniquely supports - **Official skills** — reference a platform-provided skill without uploading or managing its lifecycle. See [`examples/bailian/official-skill/`](../../examples/bailian/official-skill/). diff --git a/docs/guides/deploy-to-qoder.md b/docs/guides/deploy-to-qoder.md index 4ea6d17..9e783c0 100644 --- a/docs/guides/deploy-to-qoder.md +++ b/docs/guides/deploy-to-qoder.md @@ -1,6 +1,6 @@ # Deploy to Qoder -Qoder is a managed-agent platform with native **memory stores** and **deployments**, but no multi-agent primitive. +Qoder is a managed-agent platform with native **memory stores**, **deployments**, and **multi-agent coordinators**, in both Managed Agent and Forward Template delivery. ## Provider configuration @@ -23,7 +23,7 @@ providers: | Feature | Tier | |---------|:----:| | Environment, Vault, Skill, Agent, MCP Server, Memory Store, Deployment, Session, Identity, Channel | native | -| Multi-Agent | unsupported | +| Multi-Agent | native | A `deployment run` on Qoder creates a native Deployment Run and associated Session. Cron schedules run server-side. @@ -98,6 +98,41 @@ agents: Qoder runs `setup_script` after package installation with `/bin/bash -lc`. Scripts are limited to 64 KB of UTF-8 text and 10 minutes, and a non-zero exit prevents Session startup. Make them idempotent because they run again whenever the sandbox is rebuilt. Use vaults for credentials; never place secrets directly in a script. Qoder package declarations accept `apt`, `npm`, and `pip` only. +## Multi-agent + +A Qoder coordinator delegates to other declared agents over isolated session threads that share the environment, sandbox, and file system: + +```yaml +agents: + researcher: + model: ultimate + instructions: Research and write findings under /data/research/. + environment: dev + writer: + model: ultimate + instructions: Turn findings into reports under /data/report/. + environment: dev + lead: + model: ultimate + instructions: | + You are the lead agent coordinating a team: + - researcher: owns /data/research/ + - writer: owns /data/report/ + Delegate and synthesize their outputs. + environment: dev + multiagent: + type: coordinator + agents: [researcher, writer] +``` + +Rules and behavior: + +- Roster entries are **project logical names**; OpenAgentPack resolves them to remote ids. Nesting, cycles, self references, and rosters over 20 members are rejected at plan time. +- Members must use the **same delivery type as the coordinator**: a managed coordinator wires managed Agents by `id`; a forward coordinator (every agent declares `delivery: { qoder: { type: forward } }`) wires Forward Templates by `template_id`. See [`examples/qoder/multiagent/`](../../examples/qoder/multiagent/) and [`examples/qoder/multiagent-forward/`](../../examples/qoder/multiagent-forward/). +- Member versions are never declared: a managed coordinator's roster pins each member's current version when the coordinator is saved; a forward roster has no version field at all. +- Removing `multiagent` from the declaration clears the remote roster on update — the adapter sends Qoder's explicit `multiagent: null` for you. +- Qoder's `self` references, Advisors, explicit member versions, and Thread management are not exposed by the common schema yet. + ## What Qoder uniquely supports - **Memory stores** — persistent context for an agent. See [`examples/qoder/with-memory/`](../../examples/qoder/with-memory/). diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 2b77248..475226d 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -303,6 +303,9 @@ agents: delete_on_destroy: # optional; defaults to false (retain) environment_variables: { : } # Qoder only managed_tool_config: { enabled_tools: [ ] } # Qoder Forward delivery only + delivery: # optional; per-provider remote materialization + qoder: + type: managed | forward resources: [ SessionResource ] multiagent: { type: "coordinator", agents: [...] } metadata: { : } @@ -375,9 +378,10 @@ If the preflight cannot resolve the Identity, Template, or Store lookup, destroy | `default_memory_store.delete_on_destroy` | boolean | no | Permanently delete the Store during destroy. Defaults to `false` (retain). | | `environment_variables` | map | no | Qoder runtime variables. Managed Sessions use Qoder's `KEY=VALUE;...` wire format; Forward Templates store the map as defaults and Forward Sessions send it under `config.environment_variables`. | | `managed_tool_config.enabled_tools` | string[] | no | Provider-operated tools the Agent Harness exposes, e.g. `create_forward_schedule`, `list_forward_schedules`, `delete_forward_schedule`. Qoder Forward delivery only; declaring it on managed delivery is a validation error. | +| `delivery..type` | `"managed"` \| `"forward"` | no | Per-provider remote materialization. Omitted means the managed Agent resource. `forward` (Qoder only) materializes the agent as a Forward Template; Qoder multi-agent members must use the same delivery type as their coordinator. | | `resources` | SessionResource[] | no | Resources attached to every managed Session created for the Agent. | | `multiagent.type` | `"coordinator"` | no | Declare a coordinator agent. | -| `multiagent.agents` | string[] | yes (with multiagent) | Agents it orchestrates. | +| `multiagent.agents` | (string \| `{ agent_id: string }`)[] | yes (with multiagent) | Project logical names and, for Managed delivery, explicitly referenced external Agent ids. | | `metadata` | map | no | Free-form metadata. | For Qoder Forward delivery, a locally declared Environment is created only through the Forward Environment API. An @@ -388,6 +392,49 @@ Store cannot be shared by Managed and Forward Agents under one logical declarati the two API domains. Explicit Forward Memory Stores require `defaults.identity` and are mounted read-only to that Identity and Template. +### Multi-agent + +A coordinator agent can orchestrate project-owned agents and existing external Managed Agents: + +```yaml +agents: + researcher: { ... } + writer: { ... } + lead: + model: + instructions: | + You are the lead agent coordinating a team: ... + multiagent: + type: coordinator + agents: + - researcher + - writer + - agent_id: agent_external_reviewer +``` + +Phase 1 rules, enforced at plan time on every provider: + +- A string member is a project logical name. OpenAgentPack resolves it to the materialized remote resource and manages its lifecycle. +- `{ agent_id: ... }` explicitly references an external Managed Agent. It does not need a local `agents` declaration or state entry and OpenAgentPack never creates, updates, or destroys it. +- Qoder Forward does not accept `{ agent_id }`, because its roster requires Template ids. External Forward Templates are not exposed by the common schema yet. +- For project logical-name members, self references, nested coordinators, and any direct or indirect cycle are rejected. External members are non-owned leaves in the topology. +- A roster carries at most 20 members (Qoder and Bailian platform limits). +- On Qoder, every member must use the **same delivery type as its coordinator**: a managed coordinator references managed Agents (by `id`), a forward coordinator references Forward Templates (by `template_id`). + +Version semantics per materialization (OpenAgentPack never declares member versions): + +- **Qoder Managed**: the member reference carries only `id`; the platform pins each member's current numeric version when the coordinator is saved. +- **Qoder Forward**: the member reference carries only `template_id`; there is no member version field — members always run the Template's current content. +- **Bailian**: the member reference carries only `id`; the child Thread resolves the latest member version when it is first created and keeps that version for its lifetime. + +Update and clear semantics are hidden by the provider adapters: removing `multiagent` from a coordinator declaration clears the remote roster on update (Qoder sends an explicit `multiagent: null`, Bailian sends an empty array) — you never write provider-specific clear payloads yourself. Whether delegation actually happens is decided by the coordinator's system prompt and the runtime, so describe task split, delegation criteria, and output format in `instructions`. + +Bailian child agents run in **parallel over a shared file system**: files written by one member are visible to the others. Declare file/directory ownership in the coordinator's `instructions` (and in each member's) to keep parallel members from writing the same path. + +`self` references, Qoder Advisors, explicit member versions, external Forward Templates, and Thread management are not exposed by the common schema yet. + +`agents sync` reverse-maps project-owned members to logical names via the `agents.project`/`agents.resource` metadata OpenAgentPack injects, and preserves non-owned members as `{ agent_id }`. The sync fails closed — without touching your existing file — when a project-owned roster member is archived, missing from the account listing, lacks a logical name, or when two remote agents claim the same logical name. + ### Session resources Qoder and Claude managed Sessions support a provider-neutral GitHub repository resource: diff --git a/docs/reference/multi-agent-live-probe-results.md b/docs/reference/multi-agent-live-probe-results.md new file mode 100644 index 0000000..89bed79 --- /dev/null +++ b/docs/reference/multi-agent-live-probe-results.md @@ -0,0 +1,165 @@ +# Multi-Agent Live Probe Results + +Sanitized execution records for `packages/sdk/tests/e2e/multiagent-live.ts` (step 10 of +`docs/architecture/multi-agent-implementation-plan.md`). One run per mode: `qoder-managed`, +`qoder-forward`, `bailian`. All output below is verbatim probe stdout — it contains only the +probe's own `oap-ma-live-*` test resource ids; no tokens, no user metadata, no other resources +are listed. Every resource created during each run was removed in the probe's `finally` cleanup +(verified in the trailing cleanup block of each run). + +## qoder-managed — 2026-09-22 + +```text +$ bun packages/sdk/tests/e2e/multiagent-live.ts qoder-managed + +=== Multi-Agent Live Probe: qoder-managed (prefix oap-ma-live-mucu9ycr) === + +1. validate()... + ✅ validate passed + +2. createEnvironment()... + ✅ created environment: env_00q8c2jags83l12bakzp + +3. createAgent() worker... + ✅ created worker agent: agent_00q8c2jhbz18gar8ua33 (version=1) + +4. createAgent() coordinator with multiagent roster... + ✅ created coordinator agent: agent_00q8c2jo9qwhsoa32foy (version=1) + +5. readComparableResource() — roster wired with member id... + ✅ remote roster = {"type":"coordinator","member_ids":["agent_00q8c2jhbz18gar8ua33"]} + +6. createSession() + sendSessionMessage() on the coordinator... + ✅ session sess_00q8c2k1b7n5sr84el7m: status=idle, events={"session.status_running":1,"session.thread_status_running":3,"user.message":1,"span.model_request_start":3,"agent.tool_use":1,"span.model_request_end":3,"session.thread_created":1,"agent.thread_message_sent":1,"agent.tool_result":1,"session.thread_status_idle":3,"agent.thread_message_received":1,"agent.message":1,"session.usage":1,"session.status_idle":1}, threaded=0 + +7. updateAgent() without multiagent — roster must clear (multiagent: null)... + ✅ remote roster cleared + +=== Multi-Agent live probe (qoder-managed) passed! === + +🧹 Cleanup (qoder-managed) — destroy order is the reverse of creation + 🧹 removed session: sess_00q8c2k1b7n5sr84el7m + 🧹 archived/deleted coordinator: agent_00q8c2jo9qwhsoa32foy + 🧹 archived/deleted worker: agent_00q8c2jhbz18gar8ua33 + 🧹 deleted environment: env_00q8c2jags83l12bakzp +``` + +Notes: `threaded=0` means no event carried `session_thread_id`; Qoder thread-id surfacing is out +of Phase 1 scope (Session Thread interfaces are a plan non-goal), and the probe's pass criteria do +not depend on it. An earlier qoder-managed run left environment `env_00q8brhhrfw8yqv4kdxt` behind +because cleanup passed `cascade=false`; the probe now passes `cascade=true` (Qoder rejects +environment deletion with "referenced by 0 session(s): . Use --cascade" even when zero sessions +remain), and the leftover environment was deleted and verified gone via `findResource` before this +recorded re-run. + +## qoder-forward — 2026-09-22 + +```text +$ bun packages/sdk/tests/e2e/multiagent-live.ts qoder-forward + +=== Multi-Agent Live Probe: qoder-forward (prefix oap-ma-live-mucuahbf) === + +1. validate()... + ✅ validate passed + +2. createIdentity()... + ✅ created identity: idn_1848bb09c3963960fb2f5c94 + +3. createEnvironment(forward)... + ✅ created forward environment: env_00q8c3uu7paf52hmk7wh + +4. createTemplate() worker... + ✅ created worker template: tmpl_c615e13dd02aa8d5d7675223 + +5. createTemplate() coordinator with multiagent roster... + ✅ created coordinator template: tmpl_e663465e1c8c2e1410e8d43a + +6. readComparableResource() — roster wired with template_id... + ✅ remote roster = {"type":"coordinator","member_ids":["tmpl_c615e13dd02aa8d5d7675223"]} + +7. createSession(forward) + sendSessionMessage() on the coordinator... + ✅ session sess_00q8c3vzwj8xsqcx5l1z: status=idle, events={"session.status_running":1,"session.thread_status_running":3,"user.message":1,"span.model_request_start":3,"agent.tool_use":1,"span.model_request_end":3,"session.thread_created":1,"agent.thread_message_sent":1,"agent.tool_result":1,"agent.thread_message_received":1,"agent.message":1,"session.usage":1,"session.status_idle":1}, threaded=0 + +8. updateTemplate() without multiagent — roster must clear (multiagent: null)... + ✅ remote roster cleared + +=== Multi-Agent live probe (qoder-forward) passed! === + +🧹 Cleanup (qoder-forward) — destroy order is the reverse of creation + 🧹 removed session: sess_00q8c3vzwj8xsqcx5l1z + 🧹 archived coordinator: tmpl_e663465e1c8c2e1410e8d43a + 🧹 archived worker: tmpl_c615e13dd02aa8d5d7675223 + 🧹 deleted identity: idn_1848bb09c3963960fb2f5c94 + 🧹 deleted environment: env_00q8c3uu7paf52hmk7wh +``` + +## bailian — 2026-09-22 + +```text +$ bun packages/sdk/tests/e2e/multiagent-live.ts bailian + +=== Multi-Agent Live Probe: bailian (prefix oap-ma-live-mucu4tos) === + +1. validate()... + ✅ validate passed + +2. createEnvironment()... + ✅ created environment: env_ZTEzMDg0ZmU4MGY3NDBkZj + +3. createAgent() worker... + ✅ created worker agent: agent_01M34W2QPZR96PV5FRZEVW75VS (version=1) + +4. createAgent() coordinator with multiagent roster... + ✅ created coordinator agent: agent_01M34W2QVVMFCB5VR2JB4CSQT5 (version=1) + +5. readComparableResource() — roster wired with member id... + ✅ remote roster = {"type":"coordinator","member_ids":["agent_01M34W2QPZR96PV5FRZEVW75VS"]} + +6. createSession() + sendSessionMessage() on the coordinator... + ✅ session sesn_01M34W2R2C6JFB30QE0ANY0ET6: status=idle, events={"session_status":2,"message":3,"model_request_start":5,"reasoning":5,"model_request_end":5,"tool_call":3,"thread_created":1,"thread_message_sent":2,"thread_message_received":2,"thread_status":2,"tool_call_output":3}, threaded=3 + +7. updateAgent() without multiagent — roster must clear (empty coordinator)... + ✅ remote roster cleared + +=== Multi-Agent live probe (bailian) passed! === + +🧹 Cleanup (bailian) — destroy order is the reverse of creation + 🧹 removed session: sesn_01M34W2R2C6JFB30QE0ANY0ET6 + 🧹 archived/deleted coordinator: agent_01M34W2QVVMFCB5VR2JB4CSQT5 + 🧹 archived/deleted worker: agent_01M34W2QPZR96PV5FRZEVW75VS + 🧹 deleted environment: env_ZTEzMDg0ZmU4MGY3NDBkZj +``` + +`threaded=3` here reflects Bailian's mapper surfacing `session_thread_id` on child-thread events +(`thread_created`, `thread_message_sent`, `thread_message_received`), confirming real coordinator → +worker delegation in the run. + +## External Managed Agent references — 2026-09-23 + +The Managed probes were rerun after extending `multiagent.agents` with `{ agent_id }`. In both +runs the probe created a temporary worker directly in the Provider, deliberately omitted it from +the coordinator's `ProjectConfig` and local state, and then resolved this declaration: + +```yaml +multiagent: + type: coordinator + agents: + - agent_id: +``` + +| Provider | Create/read-back | Real coordinator turn | Clear roster | Cleanup | +| --- | --- | --- | --- | --- | +| Qoder Managed | Passed; remote roster contained the external Agent id | Passed; child-Agent tool/thread events observed | Passed (`multiagent: null`) | Session, coordinator, external worker, and Environment removed | +| Bailian Managed | Passed; remote roster contained the external Agent id | Passed; 5 events carried child-thread ids | Passed (empty coordinator roster) | Session, coordinator, external worker, and Environment removed | + +Commands: + +```text +bun packages/sdk/tests/e2e/multiagent-live.ts qoder-managed +bun packages/sdk/tests/e2e/multiagent-live.ts bailian +``` + +Both commands exited with status 0. Credentials were loaded from the repository `.env`; no +credential values were logged or modified. Qoder Forward was not rerun for this extension because +the public schema intentionally rejects `{ agent_id }` for Forward rosters, which require +`template_id`. diff --git a/docs/reference/multi-agent-qoder-bailian-research.md b/docs/reference/multi-agent-qoder-bailian-research.md new file mode 100644 index 0000000..92dbb92 --- /dev/null +++ b/docs/reference/multi-agent-qoder-bailian-research.md @@ -0,0 +1,257 @@ +# Qoder 与百炼 Multi-Agent 能力调研 + +> 调研日期:2026-09-22 +> 范围:Qoder Cloud Agents 与阿里云百炼 Agent Studio 的官方 Multi-Agent 文档,以及 OpenAgentPack 当前实现。平台能力与仓库实现能力分开陈述。 + +## 结论摘要 + +Qoder 和百炼目前都原生提供 `coordinator` 编队,因此 OpenAgentPack 中两者仍标记为 `unsupported` 的能力矩阵已经过时,应改为支持。 + +- **Qoder 是完整的多 Agent 运行时模型,且 Managed Mode 与 Forward Mode 都有官方契约**:Managed Agent roster 通过 `id`/`version` 引用 Agent;Forward Template roster 通过 `template_id` 引用另一个 Template,两种模式不能共用同一请求映射。协调者将工作委派给独立 Session Thread,子 Agent 可拥有各自的模型、提示词、工具、MCP 与 Skill;线程上下文隔离但共享环境、沙箱和文件系统;平台还定义了 Advisor、线程事件流、单线程中断、工具确认和清晰的数量/深度限制。[Qoder:How it works](https://docs.qoder.com/cloud-agents/multi-agents#how-it-works) +- **百炼的多智能体配置页主要给出 roster 契约,Session/Event 与 Webhook API 补充了部分运行时契约,真机进一步证实了独立 child Thread、并行执行和 Session 内共享文件系统**:支持 `coordinator`,成员为另一个 Agent 或 coordinator 自身,条目数最多 20;事件可携带 child Thread 标识并支持定向中断。公开 API 仍没有完整 Thread CRUD。[百炼:编队配置](https://docs.agent.bailian.aliyun.com/zh/managed-agents/build-agent/multiagent#%E7%BC%96%E9%98%9F%E9%85%8D%E7%BD%AE);[百炼:发送 Event](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/session/send-event) +- 因此,第一阶段可以让两个 provider 都支持 OpenAgentPack 已有的最小公共模型 `multiagent: { type: coordinator, agents: string[] }`;Qoder 的 Advisor、`self`、版本选择和 Thread API 不应被误称为已经由该公共模型覆盖。 +- 多 Agent 是否真正发生由 coordinator 的系统提示词与运行时判断决定。仅配置 roster 不保证委派;至少 Qoder 官方明确说明 coordinator 仍会自主决定是否及如何委派。[Qoder:Create and run a Session](https://docs.qoder.com/cloud-agents/multi-agents#create-and-run-a-session) + +## 能力对比 + +| 维度 | Qoder Cloud Agents | 阿里云百炼 Agent Studio | 对 OpenAgentPack 的含义 | +|---|---|---|---| +| 概念模型 | 一个 Session 中有唯一 coordinator;普通成员在独立 Session Thread 中执行;还可配置只提供意见、不执行工具的 Advisor。[来源](https://docs.qoder.com/cloud-agents/multi-agents#how-it-works) | 一个 `coordinator` 编排成员 Agent;不配置 `multiagent` 或清空编队时按单 Agent 运行。[来源](https://docs.agent.bailian.aliyun.com/zh/managed-agents/build-agent/multiagent) | 两者的最小交集是 coordinator + roster。Advisor 仅能作为 Qoder 扩展。 | +| 角色与编排 | coordinator 负责拆解、选人、追问、汇总;child Agent 可有独立模型、提示词、工具、MCP、Skill;Advisor 只能建议。[来源](https://docs.qoder.com/cloud-agents/multi-agents#how-it-works) | 成员类型为 `agent` 或 `self`;当前只支持 `coordinator` 拓扑。[来源](https://docs.agent.bailian.aliyun.com/zh/managed-agents/build-agent/multiagent#%E7%BC%96%E9%98%9F%E9%85%8D%E7%BD%AE) | 当前公共 schema 能表达引用其他逻辑 Agent,但不能表达 `self`、Advisor 或成员版本。 | +| 任务分发 | coordinator 根据 system prompt 从 `multiagent.agents` 选择成员;官方建议明确任务拆分、委派标准、输出格式和冲突处理。[来源](https://docs.qoder.com/cloud-agents/multi-agents#what-to-delegate) | 本页只说明 coordinator “编排”成员,没有公开选择算法、任务消息或冲突处理契约。[来源](https://docs.agent.bailian.aliyun.com/zh/managed-agents/build-agent/multiagent) | 应把职责与委派规则写入 coordinator 的 `instructions`;不能由配置 roster 推断固定路由。 | +| 上下文与状态 | 每个 Thread 有独立对话历史、Agent 版本快照和状态;线程共享 Environment、Sandbox、文件系统,Session 绑定的 Vault 可供获权 Agent 使用。[来源](https://docs.qoder.com/cloud-agents/multi-agents#how-it-works) | 真机事件为两个 child 返回不同 `sthr` ID;worker A 写入 `/mnt/data/oap_shared_probe.txt` 后,worker B 成功读出 `SHARED_TOKEN_7391`,证明该 Session 的 child 共享文件系统。 | 两端都应按独立 Thread、共享文件系统处理;不同 Thread ID直接证明执行线程隔离,但单次实验本身不能完整证明所有内存态或对话内容绝不会跨线程泄露。 | +| 版本快照 | 数字版本用于固定复现;`latest` 让新 Session 采用最新子 Agent;省略版本会在保存 coordinator 时固定当时版本;既有 Session 继续使用原快照。[来源](https://docs.qoder.com/cloud-agents/multi-agents#agent-versions-and-session-snapshots) | `type=agent` 省略版本时,coordinator 保存值保持 `null`。真机在 worker v1 时创建 Session A但不发消息,更新 worker 到 v2 后首次委派,child 明确回显 `version:2`;再更新到 v3 后,同一 Session 再次委派复用同一 `sthr` 且仍为 v2。 | 两端省略 version 语义不同:Qoder 保存 coordinator 时固化数字版本;百炼在 child Thread 首次创建/执行时解析最新版本,并在该 Thread 生命周期内固定,不会每次调用重新解析。 | +| 工具、MCP 与权限 | 普通成员或 `self` 要求 coordinator 启用 `agent_toolset_20260401`;每个 Thread 使用自身 Agent 快照中的工具与权限。MCP/Skill 属于 Agent;Vault 属于 Session。需要确认的调用通过事件暂停,并用 tool-use ID 路由回复。[配置来源](https://docs.qoder.com/cloud-agents/multi-agents#configure-with-the-api);[权限来源](https://docs.qoder.com/cloud-agents/multi-agents#tool-permissions-and-interactions) | 本页的 Multi-Agent 示例没有声明额外 toolset,也未说明成员工具、MCP、密钥与权限继承规则。[来源](https://docs.agent.bailian.aliyun.com/zh/managed-agents/build-agent/multiagent#%E9%85%8D%E7%BD%AE%E7%A4%BA%E4%BE%8B) | Qoder 现有 mapper 已始终输出该 toolset;接入 roster 时须保留并补测试。百炼不要臆造相同要求。高权限凭据应只配给需要它的 Agent。 | +| 并行与串行 | 支持独立任务并行,也支持实现→评审等分阶段串行;并行线程共享文件系统,官方要求明确文件/目录所有权。[来源](https://docs.qoder.com/cloud-agents/multi-agents#what-to-delegate) | 真机中两个 child Thread 在约 130 ms 内创建并同时进入 `running`;两边 `sleep 6` 的 shell 时间戳均为 start `1790067744`、end `1790067750`,证明该次运行确实并行。 | 可以确认百炼具备并行执行能力,但单次实验不能推出固定调度顺序、并发上限或所有任务必然并行;共享文件仍需明确所有权。 | +| 可观测性 | Session SSE 展示主线程与跨线程协作事件;Thread API 可列出线程并读取单线程完整事件流。事件包括 thread created/running/rescheduled/idle/terminated 及消息收发。[来源](https://docs.qoder.com/cloud-agents/multi-agents#observe-threads-and-events) | Session 事件 `metadata` 可带 `thread_id`;Webhook 定义四类 Thread 生命周期事件并返回 `session_thread_id`;发送 Event 支持按该 ID 定向中断。公开索引未提供 Thread list/get/archive 或逐 Thread 历史端点。[Webhook 来源](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/webhook/callback) | 当前统一事件已有 `session_thread_id`,但 ProviderAdapter 没有 Thread 查询接口。第一阶段可保留事件元数据和定向中断;完整 Thread 管理仍是 Qoder 特有 facet。 | +| 人机协作 | 可按 `session_thread_id` 中断单线程;需确认/自定义工具调用会进入 `requires_action`,客户端必须逐一响应未决事件;Advisor 失败不阻断主任务。[中断](https://docs.qoder.com/cloud-agents/multi-agents#interrupt-one-thread);[工具交互](https://docs.qoder.com/cloud-agents/multi-agents#tool-permissions-and-interactions);[Advisor 失败](https://docs.qoder.com/cloud-agents/multi-agents#events-and-failures) | 本页没有 Multi-Agent 专属的人机介入、暂停、审批或恢复说明。 | Qoder 的 `requires_action` 不能被 UI 当作完成态;工具回复按 tool-use ID 路由,不应附 thread ID。 | +| 限制 | 最多 20 个不同普通成员 + 1 Advisor;每 Session 最多 25 个未归档普通 Thread(含 coordinator);只有 coordinator 能创建子线程,不能嵌套委派;普通成员/`self` 需要指定 toolset。[来源](https://docs.qoder.com/cloud-agents/multi-agents#limits) | 编队条目 1–20;`self` 最多一个;`agent.id` 最长 64 字符;清空数组用于取消编队;当前仅有 coordinator 拓扑。[来源](https://docs.agent.bailian.aliyun.com/zh/managed-agents/build-agent/multiagent#%E7%BC%96%E9%98%9F%E9%85%8D%E7%BD%AE) | parser 可提前校验 roster 数量,但 provider 特有规则应留在 provider 映射/校验层。 | +| 适用场景 | 独立调研、按模块实现、数据收集、实现/测试/评审分工、需要更强模型或专用工具的路由、分阶段迭代。[来源](https://docs.qoder.com/cloud-agents/multi-agents#what-to-delegate) | 本页只给出 coordinator + researcher 配置示例,没有列举更细场景。[来源](https://docs.agent.bailian.aliyun.com/zh/managed-agents/build-agent/multiagent#%E9%85%8D%E7%BD%AE%E7%A4%BA%E4%BE%8B) | 适合可按职责边界拆分的复杂任务;一步任务、强顺序任务和多人频繁修改同一文件的任务优先单 Agent。 | + +## 关键配置差异 + +### 已证实 / 待真机验证矩阵 + +下表将官方文档和本次隔离真机测试证实的契约,与证据仍有边界的行为分开。实现不得把单次运行结果外推成平台未承诺的普遍保证。本次真机测试没有在报告中记录任何凭据;临时 Qoder 资源已删除,百炼 Agent 已归档、Environment 与 Session 已删除。 + +| 问题 | Qoder Managed Mode | Qoder Forward Mode | 百炼 Managed Agents | 实施结论 | +|---|---|---|---|---| +| 适用资源 | **文档已证实**:`Agent.multiagent`,创建 `POST /api/v1/cloud/agents`。[创建 Agent](https://docs.qoder.com/cloud-agents/api/agents/create) | **文档已证实**:`Template.multiagent`,创建 `POST /api/v1/forward/templates`;成员引用使用 `template_id`。[创建 Template](https://docs.qoder.com/cloud-agents/api/forward/templates/create) | **文档已证实**:Agent 创建/更新 API 的 `multiagent` 字段。[创建 Agent](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/agent/create) | 第一阶段必须先声明 Qoder materialization 是 Managed Agent 还是 Forward Template;不能只把 capability 全局改成 `native` 后复用同一 mapper。 | +| 创建 roster | **文档和真机均已证实**:`{type:"coordinator",agents:[{type:"agent",id,version?},{type:"self"}, advisor?]}`;真机以省略成员 version 的对象创建 worker + coordinator 成功。 | **文档已证实**:`{type:"coordinator",agents:[{type:"agent",template_id,name?},{type:"self"}, advisor?]}`;普通成员没有 Managed 的 `version` 字段。[Template 创建](https://docs.qoder.com/cloud-agents/api/forward/templates/create#multiagent) | **文档和真机均已证实**:`{type:"coordinator",agents:[{type:"agent",id,version?},{type:"self"}]}`,1–20 项;真机创建 worker + coordinator 成功。 | Provider 内要按 materialization 分支生成对象,禁止把逻辑名或远端 ID 字符串混入错误资源类型。 | +| 更新 / 清除 | **文档和真机均已证实**:更新是 merge update,`version` 必填作 OCC;省略 `multiagent` 保留,`multiagent:null` 清除。真机传 `agents:[]` 返回 400,传 `multiagent:null` 成功且 GET 为 `null`。[更新 Agent](https://docs.qoder.com/cloud-agents/api/agents/update) | **文档和真机均已证实**:`POST /templates/{id}` 为 merge update;真机确认省略 `multiagent` 保留、对象整体替换成功、`null` 清除;成员 `name` 按调用方提交值原样回显。[更新 Template](https://docs.qoder.com/cloud-agents/api/forward/templates/update) | **文档和真机均已证实**:更新是全量替换,`version` 必填作 OCC,缺省字段视为清空;真机传 `{type:"coordinator",agents:[]}` 成功且 GET `multiagent:null`。[更新 Agent](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/agent/update) | 两端不能共用 patch 策略。百炼更新必须构造完整 Agent 配置,避免无关字段被清空;Qoder Managed 与 Forward 删除 roster 都传 `multiagent:null`。Forward 的 `name` 是调用方数据,不能据此假定它是 OpenAgentPack 逻辑键。 | +| 成员 `version` 省略 | **文档和真机均已证实**:保存 coordinator 时解析并固定为成员当前数字版本;真机请求只传 `{type:"agent",id}`,GET 自动回显 `version:1`。只有显式 `"latest"` 才让未来新 Session 采用当时最新版本。[Schema](https://docs.qoder.com/cloud-agents/api/agents/schemas#multiagent-agent-entry) | **文档已证实**:成员按 `template_id` 引用,公开 Template roster 契约不提供成员 `version`。 | **真机已精确证实时点**:worker v1 时先创建未运行的 Session A;更新到 v2 后首次委派,child 回显同一 worker 的 `id/name/version:2`,排除 Session 创建时解析。再更新到 v3 后于同一 Session 再次委派,复用同一 `sthr` 且仍回显 v2,证明 child Thread 创建后版本固定。 | 百炼 `null` 表示在 child Thread 首次创建/执行时解析最新版本,并在该 Thread 内固定;reverse 不能伪造成 coordinator 保存时已经固定的数字版本。 | +| Toolset | **文档和真机均已证实**:普通成员或 `self` roster 需要 `agent_toolset_20260401`;真机 GET 回显该 toolset。 | **文档已证实**:普通成员或 `self` 同样需要该 toolset,且 Forward 创建 Template 会自动添加。[创建 Template](https://docs.qoder.com/cloud-agents/api/forward/templates/create#multiagent) | **未发现等价要求**。 | Qoder mapper 和测试必须保留 toolset;百炼不得臆造同一字段。 | +| 读取 / reverse 形状 | **文档和真机已证实**:GET 返回完整 `multiagent`,成员为远端 `id` + 保存后的 `version`,没有 OpenAgentPack 逻辑名;调用方传的 `name` 会被忽略。[Schema](https://docs.qoder.com/cloud-agents/api/agents/schemas#multiagent-agent-entry) | **文档已证实**:Template 使用 `template_id`,可含展示 `name`;不能假定该名称就是 OpenAgentPack 逻辑键。[获取 Template](https://docs.qoder.com/cloud-agents/api/forward/templates/get) | **文档和真机已证实**:GET/List 返回完整 `multiagent`,成员为 `{type,id,version}`;省略 version 的真机响应为 `null`,没有成员逻辑名。[获取 Agent](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/agent/get) | reverse mapper 必须用同一 workspace 资源清单建立 `remote id -> local logical name` 索引;无法映射时应保留 unresolved reference 或报错,不能静默丢弃。 | +| 嵌套 / 循环 | **真机已证实配置层宽松**:保存 coordinator→coordinator 成功;保存 A↔B 直接循环也成功。运行时文档仍规定只有根 coordinator Thread 能创建 child、不能嵌套委派。[限制](https://docs.qoder.com/cloud-agents/multi-agents#limits) | **真机已证实配置层同样宽松**:保存 A↔B Template 直接循环成功。 | **真机已证实**:保存 coordinator→coordinator 成功;建立 A↔B 时第二条边返回 HTTP 400、`AGENT_010`。无环 outer→inner→worker 运行仅创建 inner 的一个 child Thread,没有 worker 的第二个 `sthr`,inner 自身返回结果,未发生二级委派。 | OpenAgentPack 必须在本地拒绝所有循环;Qoder Managed/Forward 不能依赖平台,百炼错误也只是额外防线。百炼单次运行不能证明永远不支持递归编排,但足以支持第一期统一禁止嵌套。 | +| Thread API | **文档已证实**:list/get/archive thread,list/stream thread events;base path `/api/v1/cloud/sessions/{session_id}/threads`。List 返回 `{data,first_id,last_id,has_more,next_page}`,Thread 含 `id,type,session_id,parent_thread_id,agent,status,stats,archived_at,created_at,updated_at`。[List Threads](https://docs.qoder.com/cloud-agents/api/sessions/threads/list) | **文档已证实**:Forward 也提供 list/get/archive Thread 及 list/stream Thread Events,base path `/api/v1/forward/sessions/...`。[Forward List Threads](https://docs.qoder.com/cloud-agents/api/forward/sessions/list-threads) | **部分运行时契约已证实**:事件/Webhook 暴露 `session_thread_id` 并支持定向中断;公开 Managed Agents API 索引未给出等价 child Thread 查询/归档 API。 | 完整 Thread facet 可先做 Qoder 两模式可选能力;百炼可先保留 child Thread 标识和定向中断,不应宣称完整 CRUD。 | +| API / 资源清理端点 | **文档已证实**:base URL `https://api.qoder.com/api/v1/cloud`。[API Overview](https://docs.qoder.com/cloud-agents/api/conventions/overview#gateway-url) 真机临时 Agent 已通过 DELETE 清理。 | **文档已证实**:base URL `https://api.qoder.com/api/v1/forward`;本轮临时 Template 与 Environment 已清理。 | **文档已证实**:`https://{workspace_id}.{region}.maas.aliyuncs.com/api/v1/agentstudio`。真机确认 Agent 清理由 `POST /agents/{id}/archive` 完成,`DELETE` 返回 405;本轮 Agent 已归档、Environment 与 Session 已删除。 | base URL 由 provider 配置和 region/workspace 派生。测试清理逻辑也必须 provider-specific。 | + +本轮隔离真机已经覆盖成员版本、新 Session、嵌套/循环、百炼无环嵌套运行时、Qoder Forward 更新、百炼 child 事件、共享文件系统与并行执行。仍未覆盖的是两端同名、归档和无权成员下 reverse 的 ID→逻辑名歧义与失败策略。百炼无环嵌套已观测到不发生二级委派,但单次运行不能外推成平台在所有模型、提示词和未来版本下绝对不会递归编排。 + +### 隔离真机实验契约(可直接执行) + +以下是根据官方 API 一手契约整理的最小实验。变量中的 ID 均应来自同一隔离测试租户/工作空间;资源名称应带唯一前缀,并在实验后归档或删除。配置请求可以证明“配置是否被接受、响应如何归一化”;事件、Agent 快照、独立 Thread ID 和重叠的工具时间戳可以分别证明本次运行的版本、线程与并行事实,但单次实验不能外推成所有条件下的调度顺序、并发上限或隐藏状态隔离保证。 + +#### Qoder Forward + +基地址为 `https://api.qoder.com/api/v1/forward`。先创建 Environment: + +```http +POST /environments +Content-Type: application/json + +{"name":"oap-ma-probe-env","config":{"type":"cloud"}} +``` + +分别创建普通 worker Template 和 coordinator Template。`model` 必须使用 `GET /models` 返回的可用 ID;普通成员由 `template_id` 引用,Forward 在创建含普通成员的 coordinator 时会自动补 `agent_toolset_20260401`,但实验 payload 显式携带它更便于断言回显。 + +```http +POST /templates + +{"name":"oap-ma-probe-worker","model":"","environment_id":"","system":"Return WORKER_OK."} +``` + +```http +POST /templates + +{"name":"oap-ma-probe-coordinator","model":"","environment_id":"","system":"Delegate the task to the worker and report its exact result.","tools":[{"type":"agent_toolset_20260401"}],"multiagent":{"type":"coordinator","agents":[{"type":"agent","template_id":"","name":"worker"}]}} +``` + +更新为新 roster、保持和清除的精确契约已经由官方文档和真机共同证实:`POST /templates/{template_id}` 是 merge update;真机确认省略 `multiagent` 保留原值、提供对象时整体替换成功、`{"multiagent":null}` 清除。GET 中成员 `name` 按调用方提交值原样回显,因此不能把它无条件当作 OpenAgentPack 逻辑键。临时 Forward Template 与 Environment 已全部清理。 + +创建 Forward Session 还需要 Identity;使用已有隔离 Identity,或按 Forward Identity API 创建。最小 Session payload 为: + +```http +POST /sessions + +{"identity_id":"","template_id":"","title":"oap-ma-probe"} +``` + +用 Session Event API 发送要求必须委派且返回固定标记的消息后,查询线程及线程事件: + +```http +GET /sessions/{session_id}/threads?limit=100 +GET /sessions/{session_id}/threads/{thread_id}/events?limit=100 +``` + +线程列表同时包含 coordinator 与 child,结构中可观测 `id`、`type`、`parent_thread_id`、`agent`、`status` 等字段;child 是否由目标 Template 产生,应以 `parent_thread_id` 和 `agent` 快照共同判断,不能仅凭响应文本猜测。Thread Event 历史不支持 Session Event 的 `types`、`order`、`include_tool_calls` 等筛选参数。[创建 Template](https://docs.qoder.com/cloud-agents/api/forward/templates/create);[更新 Template](https://docs.qoder.com/cloud-agents/api/forward/templates/update);[创建 Session](https://docs.qoder.com/cloud-agents/api/forward/sessions/create);[列出 Threads](https://docs.qoder.com/cloud-agents/api/forward/sessions/list-threads);[列出 Thread Events](https://docs.qoder.com/cloud-agents/api/forward/sessions/list-thread-events) + +#### 嵌套与循环 + +真机结果已经回答配置层问题:Qoder Managed 保存 `coordinator -> coordinator` 成功,保存 A↔B 直接循环也成功;Qoder Forward 保存 A↔B Template 直接循环同样成功。两种 materialization 的配置层都不能承担循环校验。Managed 成员对象使用 `{"type":"agent","id":""}`,Forward 使用 `{"type":"agent","template_id":""}`,coordinator 均需 orchestration toolset。 + +该证据严格证明“保存层接受这些图”,不证明运行时会递归委派;Qoder 官方仍规定只有根 coordinator Thread 能创建 child。因此 OpenAgentPack 必须独立拒绝静态循环,不能把平台 API 当作循环校验器。百炼对比结果是:保存 `coordinator -> coordinator` 成功,但建立 A↔B 的第二条边返回 HTTP 400、`AGENT_010` 循环引用。 + +百炼无环嵌套还做了运行时真机:outer coordinator 的 roster 引用 inner coordinator,inner 的 roster 引用 worker;用户明确要求按 outer→inner→worker 执行 `echo NESTED_SUCCESS`。事件只创建一个 child Thread,`agent_name=inner`,没有为 worker 创建第二个 `sthr`;最终由 inner 自身返回执行结果。该事件证据严格证明本次运行没有二级委派,配置层接受嵌套不等于运行时递归编排。单次运行不足以断言平台绝对永远不会递归,但已经足以支持第一期禁止嵌套的产品约束,并避免不同 provider 对同一声明产生不同深度的执行图。 + +#### 百炼 Managed Agents + +基地址为 `https://{workspace_id}.{region}.maas.aliyuncs.com/api/v1/agentstudio`。Environment 最小 payload 只要求名称;显式写 `cloud` 便于断言: + +```http +POST /environments + +{"name":"oap-ma-probe-env","config":{"type":"cloud"}} +``` + +Agent 创建最少要求 `name` 与 `model.id`。先建 worker,再建省略成员版本的 coordinator: + +```http +POST /agents + +{"name":"oap-ma-probe-worker","model":{"id":""},"system":"Return WORKER_V1."} +``` + +```http +POST /agents + +{"name":"oap-ma-probe-coordinator","model":{"id":""},"system":"Always delegate to the worker and return its exact marker.","multiagent":{"type":"coordinator","agents":[{"type":"agent","id":""}]}} +``` + +创建 Session、发送消息、拉取历史和订阅 SSE 的最小调用为: + +```http +POST /sessions + +{"agent":"","environment_id":"","title":"oap-ma-probe"} + +POST /sessions/{session_id}/events + +{"input":[{"role":"user","type":"message","content":[{"type":"text","text":"Delegate now and return the worker marker."}]}]} + +GET /sessions/{session_id}/events?limit=100 +GET /sessions/{session_id}/events/stream +Accept: text/event-stream +``` + +百炼现在已有可观察 child Thread 的官方字段,真机事件进一步实际出现 `thread_created`、`thread_status`、`thread_message_sent`、`thread_message_received`,并为 child 返回独立 `sthr` ID。官方文档中的 Session Event `metadata` 可携带 `thread_id`,Thread 生命周期 Webhook 使用 `session.thread_created`、`session.thread_run_started`、`session.thread_idled`、`session.thread_terminated` 并在 `data.session_thread_id` 返回具体线程标识;发送 `interrupt` 可带顶层 `session_thread_id` 定向中断,工具回填也可用同字段路由到子线程。真机事件名称与 Webhook 事件名称属于不同事件面,不应强行归一成同一枚举。公开 Managed Agents API 索引仍未提供像 Qoder 那样的 Thread list/get/archive 与逐 Thread event-history 端点,因此能观察和定向控制,不等于拥有完整 Thread CRUD。[创建 Environment](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/environment/create);[创建 Agent](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/agent/create);[创建 Session](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/session/create);[列出 Event](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/session/list-events);[发送 Event](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/session/send-event);[Webhook Thread 事件](https://docs.agent.bailian.aliyun.com/zh/api/managed-agents/webhook/callback) + +#### 百炼成员版本、共享文件与并行执行真机结果 + +版本实验先在 worker v1 时创建 Session A但不发送消息,再把 worker 更新到 v2。Session A首次委派创建 child 时,完成结果明确回显目标 worker 的 `agent.id`、`agent.name` 和 `agent.version: 2`,从而排除“Session 创建时解析”,严格证明省略成员版本是在该 child Thread 首次创建/执行时解析最新版本。随后把 worker 更新到 v3,并在同一 Session A再次委派:平台复用同一 `sthr` ID,完成结果仍回显 `version: 2`。这严格证明已创建的 child Thread 会固定版本,不会在该 Thread 的每次调用中重新解析。对于同一 Session 中未来新建的另一个 child Thread,应按相同规则推断为在首次创建时解析,但本实验没有单独创建第二个新 Thread 验证这一外推。 + +共享文件实验中,worker A 写入 `/mnt/data/oap_shared_probe.txt`,worker B 成功读取精确值 `SHARED_TOKEN_7391`。这证明本次同一 Session 的不同 child 共享文件系统;它不意味着并发写同一路径具有事务隔离或确定冲突解决规则。 + +并行实验中,两个 child Thread 在约 130 ms 内创建且都进入 `running`。两边执行 `sleep 6` 的 shell 时间戳均为 start `1790067744`、end `1790067750`,排除了串行执行,严格证明该次运行并行。不同 `sthr` ID证明它们是独立执行线程;这支持线程/执行上下文隔离,但单次观察不能完整证明所有对话历史、内存态或隐藏运行时状态在任何条件下都不会互相泄露,也不能推出平台固定并发上限或调度公平性。 + +本轮全部临时 Qoder 资源已删除;百炼临时 Agent 已归档,临时 Environment 与 Session 已删除。 + +### Qoder + +Qoder 的普通成员配置是对象引用,并且 coordinator 需要 orchestration toolset: + +```json +{ + "tools": [{ "type": "agent_toolset_20260401" }], + "multiagent": { + "type": "coordinator", + "agents": [ + { "type": "agent", "id": "agent_x", "version": "latest" }, + { "type": "self" }, + { "type": "advisor", "model": "ultimate" } + ] + } +} +``` + +普通成员、`self` 和 Advisor 的完整规则见 [Qoder API 配置](https://docs.qoder.com/cloud-agents/multi-agents#configure-with-the-api)。Advisor 每个 roster 最多一个,不执行工具,每次咨询使用临时 Thread,并增加模型成本和等待时间。[Qoder:Configure an Advisor](https://docs.qoder.com/cloud-agents/multi-agents#configure-an-advisor) + +### 百炼 + +百炼同样使用对象引用,但官方本页只公布 `agent` 和 `self`: + +```json +{ + "multiagent": { + "type": "coordinator", + "agents": [ + { "type": "self" }, + { "type": "agent", "id": "agent_researcher", "version": 3 } + ] + } +} +``` + +详见[百炼配置示例](https://docs.agent.bailian.aliyun.com/zh/managed-agents/build-agent/multiagent#%E9%85%8D%E7%BD%AE%E7%A4%BA%E4%BE%8B)。该页没有 Advisor 或专用 orchestration toolset 的定义。 + +## OpenAgentPack 当前差距 + +仓库现状与上述官方能力不一致: + +1. `packages/sdk/src/internal/providers/qoder/capabilities.ts` 和 `packages/sdk/src/internal/providers/bailian/capabilities.ts` 仍将 `multiagent` 标为 `unsupported`,理由分别是平台没有该原语;这个判断已被当前官方文档否定。 +2. 公共类型 `MultiagentDecl` 只有 `{ type: "coordinator"; agents: string[] }`。它足以表达最小的“逻辑 Agent 名称 roster”,但表达不了 `self`、Qoder Advisor、成员版本策略。 +3. resolver 已能把逻辑 Agent 名称解析为 provider ID,因此可复用现有 Claude/Ark 的依赖图和创建顺序。 +4. Qoder create/update mapper 尚未输出 coordinator 配置,Agent 的 normalize/reverse mapper 也未保留 `multiagent`;但 mapper 已始终输出 `agent_toolset_20260401`,接入 roster 时应保留这一行为并增加回归测试。 +5. 百炼 mapper 尚未处理 `multiagent`。 +6. Session 事件统一类型已经保留 `session_thread_id`,但统一 `ProviderAdapter` 当前不暴露 thread list/get/archive/events/stream;这不阻塞 coordinator roster 的第一阶段支持,却限制了深度调试与单线程控制。 +7. `docs/reference/providers*.md`、部署指南和 examples 的能力矩阵仍写 Qoder/百炼不支持,需要与 capability 声明同步更新。 + +## 建议的支持范围 + +推荐顺序遵循四条原则:先实现已被官方契约和真机共同证明的最小闭环;不把 provider 差异伪装成统一语义;先保证 create/read/update/clear 可逆和无损,再增加高级运行时能力;凡是会造成远端残留配置、版本漂移或 reverse 丢失的信息,都必须显式失败而不能猜测。 + +### 第一阶段:最小公共能力 + +- 保持现有公共 YAML:`multiagent: { type: coordinator, agents: [researcher, reviewer] }`。 +- Qoder **Managed Agent** 与百炼将逻辑名称解析为 provider Agent ID,并映射为 `{type: "agent", id}`。若项目还会 materialize 为 Qoder **Forward Template**,必须另建映射为 `{type: "agent", template_id}`;不能复用 Managed payload。 +- Qoder 沿用当前自动加入 `agent_toolset_20260401` 的行为,并增加断言,避免后续重构造成配置成功但运行时无法委派。 +- 为两端启用 `multiagent: native`,补充 mapper、reverse mapper、plan/apply/diff、依赖排序和 provider conformance 测试。 +- clear 语义写成 provider 契约测试:Qoder Managed 发 `multiagent: null`;百炼全量更新并使用 `agents: []`(真机读取结果为 `multiagent: null`)。 +- reverse 必须通过同 workspace 资源索引把远端 ID 还原为逻辑名;找不到或不唯一时显式报错/标记 unresolved,不允许跳过成员。 +- coordinator 示例必须写清任务边界、委派条件、输出格式、并行文件所有权与冲突处理。 +- 公共说明可以陈述百炼真机已证实独立 child Thread、一次并行执行和同 Session 共享文件系统,但必须标注证据边界;不能宣称固定调度保证、完整上下文隔离或尚未公开的 Thread CRUD。 + +### 第二阶段:可选的扩展模型 + +在保持简单字符串 roster 向后兼容的前提下,再评估结构化成员: + +```yaml +multiagent: + type: coordinator + agents: + - agent: researcher + version: latest + - type: self + - type: advisor + model: ultimate +``` + +其中 `advisor` 是 Qoder-only;`self` 可跨 Qoder/百炼;`version` 必须明确区分“固定数字版本”“新 Session 跟随 latest”以及各 provider 省略值的不同语义。公共 schema 不应为了表面统一而抹平差异。 + +### 第三阶段:运行时线程能力 + +若产品需要“查看每个子 Agent 做了什么、只中断一个子任务、归档子线程”,再为 `ProviderAdapter` 设计可选的 Session Thread facet。Qoder 已有明确官方契约;百炼需要先从官方 API 文档确认等价端点与事件语义,不能从单个 `session_thread_id` 字段反推完整能力。 + +## 验收建议 + +- `plan` 能显示 coordinator 对成员 Agent 的依赖,`apply` 先创建成员再创建 coordinator。 +- Qoder 请求体包含对象型成员引用与所需 orchestration toolset;百炼请求体包含对象型成员引用。 +- 更新成员版本后,分别测试固定版本和 `latest` 的新 Session 行为;既有 Session 不应被误判为已热更新。 +- 两个互不写同一文件的成员可成功被委派并汇总;Qoder 同时验证跨线程事件中带有 `session_thread_id`。 +- Qoder 工具确认进入 `requires_action` 时 UI 不显示为完成,并能按 tool-use ID 恢复正确线程。 +- 超过 roster 限制、引用不存在/无权访问 Agent、循环依赖等配置在 apply 前或 provider 返回时给出明确错误。 + +## 官方来源 + +- [Qoder:Multiagent orchestration](https://docs.qoder.com/cloud-agents/multi-agents) +- [阿里云百炼 Agent Studio:多智能体协作](https://docs.agent.bailian.aliyun.com/zh/managed-agents/build-agent/multiagent) diff --git a/docs/reference/providers.md b/docs/reference/providers.md index 2cdd665..7b93254 100644 --- a/docs/reference/providers.md +++ b/docs/reference/providers.md @@ -14,7 +14,7 @@ OpenAgentPack targets multiple agent platforms behind one declarative config. Ea | Agent | native | native | native | native | Core managed-agent resource. | | MCP Server | native | native | native | native | Bailian uses official managed servers referenced by name. | | Memory Store | unsupported | native | native | native | Qoder, Claude (beta), and Ark adapters implement the complete upstream lifecycle. | -| Multi-Agent | unsupported | unsupported | native | native | Coordinator topology is available on Claude and Volcengine Ark. | +| Multi-Agent | native | native | native | native | Coordinator topology is available on all four providers. | | Deployment | native | native | native | emulated | Bailian, Qoder, and Claude use native deployments; Ark expands a deployment into a session at `run` time. | | Session | native | native | native | native | Runtime sessions are native on every provider. | @@ -46,8 +46,8 @@ The resource matrix above answers whether a declaration can be applied. The tabl ### Notable provider-specific behavior -- **Bailian:** skill upload uses the Files API and supports scan-status polling; agent updates create provider-side versions. Official MCP servers are referenced by name. Deployments are native, with server-side cron schedules, manual runs, and pause/unpause. -- **Qoder:** tool names are translated from the lowercase config vocabulary to PascalCase. Session sends return a cursor, enabling resumable event consumption. Deployments are native and support manual or scheduled runs. +- **Bailian:** skill upload uses the Files API and supports scan-status polling; agent updates create provider-side versions. Official MCP servers are referenced by name. Deployments are native, with server-side cron schedules, manual runs, and pause/unpause. Multi-agent members run in parallel and share the coordinator's file system; the coordinator roster omits versions so each child Thread resolves the latest member and pins it for its lifetime. +- **Qoder:** tool names are translated from the lowercase config vocabulary to PascalCase. Session sends return a cursor, enabling resumable event consumption. Deployments are native and support manual or scheduled runs. Multi-agent coordinators work in both delivery modes — Managed Agents reference members by `id`, Forward Templates by `template_id`, and members must share the coordinator's delivery type. - **Claude:** deployments are native, including their server-side lifecycle. It is currently the only adapter that downloads remote skill packages during `sync`. - **Volcengine Ark:** skills are create + get + attach only in the API behavior verified by this project. Updates re-upload a new skill; list and in-place update are unavailable; deletion is best-effort. Deployment is emulated as a session. diff --git a/docs/reference/providers.zh-CN.md b/docs/reference/providers.zh-CN.md index 0f43787..2d07a91 100644 --- a/docs/reference/providers.zh-CN.md +++ b/docs/reference/providers.zh-CN.md @@ -33,7 +33,7 @@ OpenAgentPack 通过 Provider 适配器与不同的 AI Agent 平台交互。每 | Agent | native | native | native | native | 核心资源 | | MCP Server | native | native | native | native | 通过 Agent 的 MCP 配置挂载 | | Memory Store | unsupported | native | native | native | Qoder、Claude(beta)、方舟均已接入 | -| Multi-Agent | unsupported | unsupported | native | native | Claude 与 火山方舟 支持 coordinator | +| Multi-Agent | native | native | native | native | 四家 Provider 均支持 coordinator 拓扑 | | Deployment | native | native | native | emulated | 百炼、Qoder 和 Claude 使用原生 Deployment;火山方舟在 `run` 时展开为 Session | | Session | native | native | native | native | 四者均原生支持 | @@ -59,8 +59,8 @@ OpenAgentPack 通过 Provider 适配器与不同的 AI Agent 平台交互。每 #### Provider 特有实现与限制 -- **百炼**:Skill 通过 Files API 上传并支持扫描状态轮询;Agent 更新会生成平台侧版本;官方 MCP Server 按名称引用;Deployment 为原生资源,支持服务端 cron 调度、手动触发和暂停/恢复。 -- **Qoder**:配置中的小写工具名会转换为 PascalCase;Session 发送返回游标,可恢复事件消费;Deployment 为原生资源,支持手动或定时运行。 +- **百炼**:Skill 通过 Files API 上传并支持扫描状态轮询;Agent 更新会生成平台侧版本;官方 MCP Server 按名称引用;Deployment 为原生资源,支持服务端 cron 调度、手动触发和暂停/恢复。Multi-Agent 成员并行执行并共享 Coordinator 的文件系统;成员名单省略版本号,由子 Thread 首次创建时解析最新成员并在 Thread 生命周期内固定。 +- **Qoder**:配置中的小写工具名会转换为 PascalCase;Session 发送返回游标,可恢复事件消费;Deployment 为原生资源,支持手动或定时运行。Multi-Agent Coordinator 在两种交付模式下均可用——Managed Agent 按 `id` 引用成员,Forward Template 按 `template_id` 引用,且成员必须与 Coordinator 使用相同交付类型。 - **Claude**:Deployment 是原生资源,具有服务端生命周期;当前只有 Claude Adapter 会在 `sync` 时下载远端 Skill 包。 - **火山方舟**:经本项目验证的 Skill API 行为仅支持创建、按 ID 查询和挂载。更新会重新上传,无法枚举和原地更新,删除为 best-effort;Deployment 由 Session 模拟。 @@ -131,10 +131,6 @@ Claude 的 drift detection 接口路径已预留;本仓库中的 live baseline bailian.memory_store.unsupported: no memory store primitive on Bailian use skill knowledge or MCP for persistent context - -qoder.multiagent.unsupported: - no multiagent primitive on Qoder. - deploy agents independently and orchestrate via MCP ``` ### 模拟(emulated)资源的能力降级 diff --git a/examples/README.md b/examples/README.md index 5691413..c26f848 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,6 +14,7 @@ examples/ │ ├── with-files/ upload local files (Files API) │ ├── with-vault/ vault │ ├── bailian-cli/ Bailian CLI integration +│ ├── multiagent/ coordinator multi-agent (Managed Agents) │ ├── deployment/ schedule + file resources (emulated -> Session on run) │ └── full/ dev + staging dual-environment full stack ├── claude/ Claude provider @@ -35,6 +36,8 @@ examples/ │ ├── github-session/ private GitHub repository mounted into each Session │ ├── vault-only/ vault-only project │ ├── multi-provider/ same agent on both Claude + Qoder +│ ├── multiagent/ coordinator multi-agent (Managed Agents) +│ ├── multiagent-forward/ coordinator multi-agent (Forward Templates) │ ├── deployment/ schedule + memory_store (native) │ ├── bailian-cli/ Bailian CLI integration │ └── full/ Qoder full-feature stack @@ -64,7 +67,7 @@ extensions, and live-test commands. | Agent | native | native | native | native | Core managed-agent resource. | | MCP Server | native | native | native | native | Bailian uses official managed servers referenced by name. | | Memory Store | unsupported | native | native | native | Qoder, Claude (beta), and Volcengine Ark. | -| Multi-Agent | unsupported | unsupported | native | native | Claude and Volcengine Ark support coordinator. | +| Multi-Agent | native | native | native | native | All four providers support the coordinator topology. | | Deployment | native | native | native | emulated | Bailian, Qoder, and Claude schedule server-side; Ark expands into a session at `run` time. | | Session | native | native | native | native | All four support runtime sessions. | | GitHub Session resource | unsupported | native | native | unsupported | Qoder and Claude clone and mount repositories at Session creation. | diff --git a/examples/bailian/multiagent/agents.yaml b/examples/bailian/multiagent/agents.yaml new file mode 100644 index 0000000..e358f8e --- /dev/null +++ b/examples/bailian/multiagent/agents.yaml @@ -0,0 +1,80 @@ +# Bailian Managed multi-agent example: a coordinator that delegates to +# researcher, writer, and an existing external reviewer. Locally declared +# agents materialize as Managed Agents. Replace `agent_external_reviewer` with +# an Agent id accessible to your Bailian workspace before applying this example. +# +# Bailian runs child agents in PARALLEL over a SHARED file system: files +# written by one member are visible to the others. The coordinator's +# instructions should declare file ownership to avoid two members writing +# the same path. +# +# Phase 1 rules enforced at plan time: +# - logical-name members must be declared in this file; external ids are non-owned +# - no self reference, no nesting, no direct or indirect cycles +# - at most 20 members per coordinator +version: "1" + +providers: + bailian: + api_key: ${DASHSCOPE_API_KEY} + workspace_id: ${BAILIAN_WORKSPACE_ID} + +defaults: + provider: bailian + +environments: + dev: + config: + type: cloud + networking: + type: unrestricted + +agents: + researcher: + description: "Gathers information from the codebase" + model: qwen3.7-max + instructions: | + You are a research agent. Your job is to gather relevant information + from the codebase when asked. Use glob and grep to find files, then + read them to extract the needed context. + + Only write files under /work/research/. Never touch /work/report/. + environment: dev + tools: + builtin: [read, glob, grep, web_search] + + writer: + description: "Writes documentation and summaries" + model: qwen3.7-max + instructions: | + You are a technical writer. Given research findings, produce clear + and concise documentation, summaries, or reports. + + Only write files under /work/report/. Never touch /work/research/. + environment: dev + tools: + builtin: [read, glob, grep] + + lead: + description: "Coordinates local agents and an external reviewer" + model: qwen3.7-max + instructions: | + You are the lead agent coordinating a team: + - researcher: can search code and the web for information, + owns /work/research/ + - writer: can produce documentation from findings, + owns /work/report/ + - external reviewer: can independently review the proposed result + + Child agents run in parallel and share one file system, so keep each + member inside its own directory. Break tasks into research and writing + phases, delegate, then synthesize their outputs into /work/report/. + environment: dev + tools: + builtin: [read, glob, grep] + multiagent: + type: coordinator + agents: + - researcher + - writer + - agent_id: agent_external_reviewer diff --git a/examples/qoder/multiagent-forward/agents.yaml b/examples/qoder/multiagent-forward/agents.yaml new file mode 100644 index 0000000..c31b989 --- /dev/null +++ b/examples/qoder/multiagent-forward/agents.yaml @@ -0,0 +1,73 @@ +# Qoder Forward multi-agent example: the coordinator and every member +# materialize as Forward Templates (`delivery.qoder.type: forward`), so the +# roster is wired with template ids on the remote side. Qoder requires all +# members of a coordinator to use the SAME delivery type as the coordinator. +# +# Phase 1 rules enforced at plan time: +# - members must be declared in this file (logical names only) +# - no self reference, no nesting, no direct or indirect cycles +# - at most 20 members per coordinator +# - Forward Template sync/export is NOT supported; only Managed Agents export +version: "1" + +providers: + qoder: + api_key: ${QODER_PAT} + +defaults: + provider: qoder + +environments: + dev: + config: + type: cloud + networking: + type: unrestricted + +agents: + researcher: + description: "Gathers information from the codebase" + model: ultimate + instructions: | + You are a research agent. Your job is to gather relevant information + from the codebase when asked. Use glob and grep to find files, then + read them to extract the needed context. + environment: dev + tools: + builtin: [Read, Glob, Grep, WebSearch] + delivery: + qoder: + type: forward + + writer: + description: "Writes documentation and summaries" + model: ultimate + instructions: | + You are a technical writer. Given research findings, produce clear + and concise documentation, summaries, or reports. + environment: dev + tools: + builtin: [Read, Glob, Grep] + delivery: + qoder: + type: forward + + lead: + description: "Coordinates researcher and writer to complete tasks" + model: ultimate + instructions: | + You are the lead agent coordinating a team: + - researcher: can search code and the web for information + - writer: can produce documentation from findings + + Break tasks into research and writing phases. Delegate to the + appropriate agent and synthesize their outputs. + environment: dev + tools: + builtin: [Read, Glob, Grep] + delivery: + qoder: + type: forward + multiagent: + type: coordinator + agents: [researcher, writer] diff --git a/examples/qoder/multiagent/agents.yaml b/examples/qoder/multiagent/agents.yaml new file mode 100644 index 0000000..d89fef4 --- /dev/null +++ b/examples/qoder/multiagent/agents.yaml @@ -0,0 +1,69 @@ +# Qoder Managed multi-agent example: a coordinator that delegates to +# researcher, writer, and an existing external reviewer. Locally declared +# agents materialize as Managed Agents (`delivery` omitted — the default). +# Replace `agent_external_reviewer` with an Agent id accessible to your account +# before applying this example against Qoder Cloud. +# +# Phase 1 rules enforced at plan time: +# - logical-name members must be declared in this file; external ids are non-owned +# - no self reference, no nesting, no direct or indirect cycles +# - at most 20 members per coordinator +version: "1" + +providers: + qoder: + api_key: ${QODER_PAT} + gateway: "https://api.qoder.com/api/v1/cloud" + +defaults: + provider: qoder + +environments: + dev: + config: + type: cloud + networking: + type: unrestricted + +agents: + researcher: + description: "Gathers information from the codebase" + model: ultimate + instructions: | + You are a research agent. Your job is to gather relevant information + from the codebase when asked. Use glob and grep to find files, then + read them to extract the needed context. + environment: dev + tools: + builtin: [Read, Glob, Grep, WebSearch] + + writer: + description: "Writes documentation and summaries" + model: ultimate + instructions: | + You are a technical writer. Given research findings, produce clear + and concise documentation, summaries, or reports. + environment: dev + tools: + builtin: [Read, Glob, Grep] + + lead: + description: "Coordinates local agents and an external reviewer" + model: ultimate + instructions: | + You are the lead agent coordinating a team: + - researcher: can search code and the web for information + - writer: can produce documentation from findings + - external reviewer: can independently review the proposed result + + Break tasks into research, writing, and review phases. Delegate to the + appropriate agents and synthesize their outputs. + environment: dev + tools: + builtin: [Read, Glob, Grep] + multiagent: + type: coordinator + agents: + - researcher + - writer + - agent_id: agent_external_reviewer diff --git a/packages/sdk/src/internal/core/agent-runtime.ts b/packages/sdk/src/internal/core/agent-runtime.ts index 26a40d9..7b39ef4 100644 --- a/packages/sdk/src/internal/core/agent-runtime.ts +++ b/packages/sdk/src/internal/core/agent-runtime.ts @@ -536,6 +536,7 @@ export function collectAgentAddresses(config: ProjectConfig, agentName: string, roots.push({ type: "identity", name: config.defaults.identity, provider: resolvedProvider }); } for (const subAgentName of declaration.multiagent?.agents ?? []) { + if (typeof subAgentName !== "string") continue; const subAgent = config.agents?.[subAgentName]; if (!subAgent) continue; roots.push({ diff --git a/packages/sdk/src/internal/core/destroy-runtime.ts b/packages/sdk/src/internal/core/destroy-runtime.ts index 7b83c36..8216233 100644 --- a/packages/sdk/src/internal/core/destroy-runtime.ts +++ b/packages/sdk/src/internal/core/destroy-runtime.ts @@ -1,9 +1,12 @@ import { UserError } from "../errors.ts"; import { ApiError } from "../providers/base-client.ts"; import type { ProviderAdapter, ProviderResourceMode } from "../providers/interface.ts"; +import type { ProjectConfig } from "../types/config.ts"; import type { RuntimeFeedbackSink } from "../types/runtime-feedback.ts"; import { emitRuntimeFeedback } from "../types/runtime-feedback.ts"; import type { ResourceState, ResourceType } from "../types/state.ts"; +import { addressKey } from "../types/state.ts"; +import { resolveAgentMaterialization } from "./agent-materialization.ts"; import type { ProjectRuntimeContext } from "./project-runtime.ts"; import { getRuntimeProvider } from "./project-runtime.ts"; @@ -78,9 +81,10 @@ const destroyOrder: Record = { }; export function planDestroyProjectContext(ctx: ProjectRuntimeContext): DestroyPlanResult { - const resources = [...ctx.state.listResources()].sort( + const tierSorted = [...ctx.state.listResources()].sort( (a, b) => (destroyOrder[a.address.type] ?? 99) - (destroyOrder[b.address.type] ?? 99), ); + const resources = destroyCoordinatorsFirst(tierSorted, ctx.config); const identityName = ctx.config.defaults?.identity; const defaultMemoryStores: DestroyDefaultMemoryStorePlan[] = []; for (const [agentName, agent] of Object.entries(ctx.config.agents ?? {})) { @@ -123,6 +127,57 @@ export function planDestroyProjectContext(ctx: ProjectRuntimeContext): DestroyPl return { resources, defaultMemoryStores, executionContext: ctx }; } +/** + * Within a type tier, a multiagent coordinator must be destroyed before its + * members: the roster references the members remotely, so deleting a member + * first leaves the coordinator pointing at a dangling id. The create graph + * orders members before coordinators; destroy reverses that edge only, leaving + * the cross-tier `destroyOrder` sequence untouched. + */ +function destroyCoordinatorsFirst(resources: ResourceState[], config: ProjectConfig): ResourceState[] { + const byAddress = new Map(); + for (const resource of resources) byAddress.set(addressKey(resource.address), resource); + + const memberResourcesOf = (resource: ResourceState): ResourceState[] => { + const decl = config.agents?.[resource.address.name]; + if (!decl?.multiagent) return []; + if (decl.provider && decl.provider !== resource.address.provider) return []; + const members: ResourceState[] = []; + for (const memberName of decl.multiagent.agents) { + if (typeof memberName !== "string") continue; + const memberDecl = config.agents?.[memberName]; + if (!memberDecl) continue; + const memberType = resolveAgentMaterialization(resource.address.provider, memberDecl).resourceType; + const member = byAddress.get( + addressKey({ type: memberType, name: memberName, provider: resource.address.provider }), + ); + if (member) members.push(member); + } + return members; + }; + + const emitted = new Set(); + const ordered: ResourceState[] = []; + const emit = (resource: ResourceState): void => { + const key = addressKey(resource.address); + if (emitted.has(key)) return; + emitted.add(key); + ordered.push(resource); + for (const member of memberResourcesOf(resource)) emit(member); + }; + + const isCoordinator = (resource: ResourceState): boolean => { + const decl = config.agents?.[resource.address.name]; + if (!decl?.multiagent) return false; + return !decl.provider || decl.provider === resource.address.provider; + }; + for (const resource of resources) { + if (isCoordinator(resource)) emit(resource); + } + for (const resource of resources) emit(resource); + return ordered; +} + export async function destroyPlannedProjectResources( planned: DestroyPlanResult, options: DestroyProjectOptions = {}, diff --git a/packages/sdk/src/internal/core/validate-config.ts b/packages/sdk/src/internal/core/validate-config.ts index 154c858..2d3e9b6 100644 --- a/packages/sdk/src/internal/core/validate-config.ts +++ b/packages/sdk/src/internal/core/validate-config.ts @@ -4,6 +4,7 @@ import "../providers/all.ts"; import { DiagnosticCollector } from "../diagnostics/diagnostics.ts"; +import { collectMultiagentTopologyDiagnostics, MULTIAGENT_MEMBER_LIMIT } from "../multiagent/topology.ts"; import { isSupported } from "../providers/capabilities.ts"; import { getProvider } from "../providers/registry.ts"; import type { ProjectConfig } from "../types/config.ts"; @@ -98,6 +99,7 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics: } if (agent.multiagent) { for (const subAgent of agent.multiagent.agents) { + if (typeof subAgent !== "string") continue; if (!agentNames.has(subAgent)) { diagnostics.error( "config.agent.multiagent.unknown", @@ -111,6 +113,8 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics: } } + collectMultiagentTopologyDiagnostics(config, diagnostics); + for (const [name, deployment] of Object.entries(config.deployments ?? {})) { if (deployment.tunnel && !tunnelNames.has(deployment.tunnel)) { diagnostics.error( @@ -464,16 +468,47 @@ export function collectProviderCapabilities( { type: "template", name, provider: providerName }, ); } - if (agent.multiagent) { + } + + if (providerName === "qoder" && agent.multiagent) { + const coordinatorMode = resolveAgentMaterialization(providerName, agent).mode; + for (const memberName of agent.multiagent.agents) { + if (typeof memberName !== "string") { + if (coordinatorMode === "forward") { + diagnostics.error( + "qoder.template.multiagent.external_member", + `agent.${name}: Qoder Forward coordinator cannot reference external Managed Agent '${memberName.agent_id}'.`, + { type: "template", name, provider: providerName }, + ); + } + continue; + } + const memberDecl = config.agents?.[memberName]; + if (!memberDecl) continue; + const memberMode = resolveAgentMaterialization(providerName, memberDecl).mode; + if (memberMode === coordinatorMode) continue; diagnostics.error( - "qoder.template.multiagent.unsupported", - `agent.${name}: multiagent is not yet supported by Qoder Forward Template delivery.`, - { type: "template", name, provider: providerName }, + "qoder.template.multiagent.member_materialization", + `agent.${name}: multiagent member '${memberName}' uses ${memberMode} delivery but the coordinator uses ${coordinatorMode}; Qoder multiagent members must match the coordinator's delivery type.`, + { type: coordinatorMode === "forward" ? "template" : "agent", name, provider: providerName }, ); } } } + if (providerName === "qoder" || providerName === "bailian") { + for (const [name, agent] of Object.entries(config.agents ?? {})) { + if (agent.provider && agent.provider !== providerName) continue; + const size = agent.multiagent?.agents.length ?? 0; + if (size <= MULTIAGENT_MEMBER_LIMIT) continue; + diagnostics.error( + `${providerName}.agent.multiagent.member_limit`, + `agent.${name}: provider '${providerName}' supports at most ${MULTIAGENT_MEMBER_LIMIT} multiagent members; got ${size}.`, + { type: resolveAgentMaterialization(providerName, agent).resourceType, name, provider: providerName }, + ); + } + } + if (providerName === "qoder") { // Qoder's /deployments API rejects tunnel_id (HTTP 400 "unknown field"), so // a declared/inherited tunnel is dropped from the deployment payload and diff --git a/packages/sdk/src/internal/executor/executor.ts b/packages/sdk/src/internal/executor/executor.ts index 2dc4a57..e982a6d 100644 --- a/packages/sdk/src/internal/executor/executor.ts +++ b/packages/sdk/src/internal/executor/executor.ts @@ -764,7 +764,7 @@ async function executeActionInner( } const hash = await computeResourceHash(address, ctx.config, ctx.configPath, ctx.state); - const comparableHash = computeComparableDesiredHash(address, ctx.config, provider); + const comparableHash = computeComparableDesiredHash(address, ctx.config, provider, ctx.state); // After apply, read back the actual remote state to establish the drift // baseline. Cloud APIs often normalize, enrich, or transform payloads, so diff --git a/packages/sdk/src/internal/executor/resolver.ts b/packages/sdk/src/internal/executor/resolver.ts index 416515c..a813cc7 100644 --- a/packages/sdk/src/internal/executor/resolver.ts +++ b/packages/sdk/src/internal/executor/resolver.ts @@ -1,4 +1,6 @@ +import { resolveAgentMaterialization } from "../core/agent-materialization.ts"; import { UserError } from "../errors.ts"; +import type { ResolvedMultiagentRoster } from "../multiagent/model.ts"; import type { ResolvedAgentRefs, ResolvedChannelRefs, @@ -6,7 +8,7 @@ import type { ResolvedTemplateRefs, } from "../providers/interface.ts"; import type { IStateManager } from "../state/state-manager.ts"; -import type { ProjectConfig } from "../types/config.ts"; +import type { AgentDecl, ProjectConfig } from "../types/config.ts"; import type { ResourceAddress } from "../types/state.ts"; export function resolveRef(state: IStateManager, address: ResourceAddress): string | null | undefined { @@ -23,6 +25,63 @@ export function requireRef(state: IStateManager, address: ResourceAddress): stri return id; } +export function resolveSkillRefs( + agent: AgentDecl, + provider: string, + state: IStateManager, +): ResolvedAgentRefs["skill_ids"] { + const skillIds: ResolvedAgentRefs["skill_ids"] = []; + for (const skill of agent.skills ?? []) { + if (typeof skill === "string") { + const id = requireRef(state, { type: "skill", name: skill, provider }); + skillIds.push({ type: "custom", skill_id: id }); + } else { + // For custom skills, resolve the skill_id (YAML key) to its remote_id + // from state. Official skills use their skill_id directly as the remote ID. + const resolvedId = + skill.type === "custom" + ? (resolveRef(state, { + type: "skill", + name: skill.skill_id, + provider, + }) ?? skill.skill_id) + : skill.skill_id; + skillIds.push({ + type: skill.type, + skill_id: resolvedId, + version: skill.version, + }); + } + } + return skillIds; +} + +function resolveMultiagentRoster( + agent: AgentDecl, + provider: string, + state: IStateManager, + resourceType: "agent" | "template", +): ResolvedMultiagentRoster { + return { + type: "coordinator", + members: (agent.multiagent?.agents ?? []).map((member) => { + if (typeof member !== "string") { + if (resourceType === "template") { + throw new UserError( + `qoder.template.multiagent.external_member: Forward coordinator cannot reference external Managed Agent '${member.agent_id}'.`, + ); + } + return { resource_type: "agent" as const, remote_id: member.agent_id }; + } + return { + logical_name: member, + resource_type: resourceType, + remote_id: requireRef(state, { type: resourceType, name: member, provider }), + }; + }), + }; +} + export function resolveAgentRefs( agentName: string, config: ProjectConfig, @@ -33,40 +92,11 @@ export function resolveAgentRefs( if (!agent) throw new UserError(`Agent '${agentName}' not found in config`); const refs: ResolvedAgentRefs = { - skill_ids: [], + skill_ids: resolveSkillRefs(agent, provider, state), }; - if (agent.skills) { - for (const skill of agent.skills) { - if (typeof skill === "string") { - const id = requireRef(state, { type: "skill", name: skill, provider }); - refs.skill_ids.push({ type: "custom", skill_id: id }); - } else { - // For custom skills, resolve the skill_id (YAML key) to its remote_id - // from state. Official skills use their skill_id directly as the remote ID. - const resolvedId = - skill.type === "custom" - ? (resolveRef(state, { - type: "skill", - name: skill.skill_id, - provider, - }) ?? skill.skill_id) - : skill.skill_id; - refs.skill_ids.push({ - type: skill.type, - skill_id: resolvedId, - version: skill.version, - }); - } - } - } - - if (agent.multiagent) { - refs.multiagent_agent_ids = []; - for (const subName of agent.multiagent.agents) { - const id = resolveRef(state, { type: "agent", name: subName, provider }); - if (id) refs.multiagent_agent_ids.push(id); - } + if (agent.multiagent && resolveAgentMaterialization(provider, agent).resourceType === "agent") { + refs.multiagent = resolveMultiagentRoster(agent, provider, state, "agent"); } return refs; @@ -93,6 +123,7 @@ export function resolveTemplateRefs( const identityName = config.defaults?.identity; return { ...agentRefs, + ...(agent.multiagent ? { multiagent: resolveMultiagentRoster(agent, provider, state, "template") } : {}), environment_id: environment.environment_id ?? requireRef(state, { type: "environment", name: agent.environment, provider }), ...(agent.tunnel ? { tunnel_id: resolveTunnelIdFromConfig(config, agent.tunnel, provider) } : {}), diff --git a/packages/sdk/src/internal/graph/dependency.ts b/packages/sdk/src/internal/graph/dependency.ts index 4312888..8397b61 100644 --- a/packages/sdk/src/internal/graph/dependency.ts +++ b/packages/sdk/src/internal/graph/dependency.ts @@ -166,6 +166,7 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str if (decl.multiagent && isSupported(caps, "multiagent")) { for (const subName of decl.multiagent.agents) { + if (typeof subName !== "string") continue; const subDecl = config.agents[subName]; const subType = subDecl ? resolveAgentMaterialization(provider, subDecl).resourceType : "agent"; const subAddr: ResourceAddress = { type: subType, name: subName, provider }; diff --git a/packages/sdk/src/internal/multiagent/comparable.ts b/packages/sdk/src/internal/multiagent/comparable.ts new file mode 100644 index 0000000..183c044 --- /dev/null +++ b/packages/sdk/src/internal/multiagent/comparable.ts @@ -0,0 +1,58 @@ +import type { ResolvedMultiagentRoster } from "./model.ts"; + +export interface ComparableMultiagentRoster { + type: "coordinator"; + member_ids: string[]; +} + +export type MultiagentMaterialization = "managed" | "forward"; + +export type CanonicalRemoteRoster = { supported: true; roster?: ComparableMultiagentRoster } | { supported: false }; + +export function canonicalizeDesiredRoster( + resolved: ResolvedMultiagentRoster | undefined, +): ComparableMultiagentRoster | undefined { + if (!resolved) return undefined; + const memberIds = [...new Set(resolved.members.map((member) => member.remote_id))].sort(); + return { type: "coordinator", member_ids: memberIds }; +} + +export function canonicalizeRemoteRoster( + raw: unknown, + materialization: MultiagentMaterialization, +): CanonicalRemoteRoster { + if (raw === null || raw === undefined) return { supported: true }; + if (typeof raw !== "object") return { supported: false }; + const roster = raw as { type?: unknown; agents?: unknown }; + if (roster.type !== "coordinator") return { supported: false }; + if (!Array.isArray(roster.agents)) return { supported: false }; + if (roster.agents.length === 0) return { supported: true }; + + const memberIds: string[] = []; + for (const entry of roster.agents) { + if (typeof entry !== "object" || entry === null) return { supported: false }; + const member = entry as { type?: unknown; id?: unknown; template_id?: unknown }; + // `self` and advisor members are deliberately rejected: Phase 1 cannot + // declare them, so treating them as ordinary agents would silently hide + // a remote roster this project can never converge to. + if (member.type !== "agent") return { supported: false }; + const id = materialization === "forward" ? member.template_id : member.id; + if (typeof id !== "string") return { supported: false }; + memberIds.push(id); + } + return { supported: true, roster: { type: "coordinator", member_ids: [...new Set(memberIds)].sort() } }; +} + +/** + * Canonical `multiagent` value for a comparable body: the roster when present, + * undefined when there is none, and a sentinel no desired canonicalization can + * produce when the remote carries members Phase 1 cannot represent. + */ +export function comparableMultiagentField( + raw: unknown, + materialization: MultiagentMaterialization, +): ComparableMultiagentRoster | { type: "unsupported" } | undefined { + const canonical = canonicalizeRemoteRoster(raw, materialization); + if (!canonical.supported) return { type: "unsupported" }; + return canonical.roster; +} diff --git a/packages/sdk/src/internal/multiagent/model.ts b/packages/sdk/src/internal/multiagent/model.ts new file mode 100644 index 0000000..7710531 --- /dev/null +++ b/packages/sdk/src/internal/multiagent/model.ts @@ -0,0 +1,11 @@ +export interface ResolvedMultiagentMember { + /** Present only when the member is owned and named by this project. */ + logical_name?: string; + resource_type: "agent" | "template"; + remote_id: string; +} + +export interface ResolvedMultiagentRoster { + type: "coordinator"; + members: ResolvedMultiagentMember[]; +} diff --git a/packages/sdk/src/internal/multiagent/reverse-index.ts b/packages/sdk/src/internal/multiagent/reverse-index.ts new file mode 100644 index 0000000..05abab9 --- /dev/null +++ b/packages/sdk/src/internal/multiagent/reverse-index.ts @@ -0,0 +1,113 @@ +import { UserError } from "../errors.ts"; +import type { MultiagentMemberDecl } from "../types/config.ts"; + +/** One `/agents` listing entry, keyed by remote agent id. */ +export interface ReverseIndexEntry { + /** Logical name from `agents.resource` metadata; only set when `agents.project` matches this project. */ + logical_name?: string; + archived: boolean; + owned: boolean; +} + +export type ReverseIndex = Map; + +/** + * Index the full `/agents` listing (archived entries included) by remote id. + * `logical_name` comes only from an explicitly present `agents.resource` whose + * `agents.project` matches — display names and ids are never used as fallbacks, + * so an agent without managed metadata simply has no logical name. + */ +export function buildReverseIndex(agents: Array>, project: string): ReverseIndex { + const index: ReverseIndex = new Map(); + for (const raw of agents) { + const id = raw.id; + if (typeof id !== "string") continue; + const metadata = raw.metadata as Record | null | undefined; + const owned = metadata?.["agents.project"] === project; + const resource = metadata?.["agents.resource"]; + const logicalName = owned && typeof resource === "string" && resource.trim() ? resource.trim() : undefined; + index.set(id, { + logical_name: logicalName, + archived: raw.archived_at != null, + owned, + }); + } + return index; +} + +/** + * Resolve roster member ids to project logical names or explicit external Agent + * references. Missing remote resources and inconsistent project-owned metadata + * still fail closed before sync writes a file. + */ +export function memberDeclResolver(index: ReverseIndex): (memberId: string) => MultiagentMemberDecl { + const claims = new Map(); + for (const entry of index.values()) { + if (!entry.logical_name) continue; + claims.set(entry.logical_name, (claims.get(entry.logical_name) ?? 0) + 1); + } + return (memberId: string): MultiagentMemberDecl => { + const entry = index.get(memberId); + if (!entry) { + throw new UserError( + `sync.multiagent.member.unresolved: roster member '${memberId}' is missing from the /agents listing.`, + ); + } + if (!entry.owned) return { agent_id: memberId }; + if (entry.archived) { + throw new UserError( + `sync.multiagent.member.archived: roster member '${memberId}' is archived; restore it before syncing.`, + ); + } + const name = entry.logical_name; + if (!name) { + throw new UserError( + `sync.multiagent.member.unresolved: roster member '${memberId}' carries no agents.resource metadata.`, + ); + } + if ((claims.get(name) ?? 0) > 1) { + throw new UserError( + `sync.multiagent.member.ambiguous: logical name '${name}' is claimed by multiple remote agents.`, + ); + } + return name; + }; +} + +/** + * Reverse-map a remote `multiagent` field into the agents.yaml decl shape — project + * logical names or explicit external Agent ids. Unrepresentable member shapes fail + * closed rather than being silently dropped. + */ +export function reverseMultiagentDecl( + raw: unknown, + resolveMember?: (memberId: string) => MultiagentMemberDecl, +): { type: "coordinator"; agents: MultiagentMemberDecl[] } | undefined { + if (raw === null || raw === undefined) return undefined; + if (typeof raw !== "object") return undefined; + const roster = raw as { type?: unknown; agents?: unknown }; + if (roster.type !== undefined && roster.type !== "coordinator") { + throw new UserError( + `sync.multiagent.member.unresolved: remote multi-agent type '${String(roster.type)}' cannot be represented (coordinator only).`, + ); + } + if (!Array.isArray(roster.agents) || roster.agents.length === 0) return undefined; + if (!resolveMember) { + throw new UserError( + "sync.multiagent.member.unresolved: reverse-mapping a multi-agent roster requires a member name resolver.", + ); + } + return { + type: "coordinator", + agents: roster.agents.map((member) => resolveMember(rosterMemberId(member))), + }; +} + +function rosterMemberId(member: unknown): string { + if (typeof member === "string") return member; + if (typeof member === "object" && member !== null) { + const m = member as { type?: unknown; id?: unknown }; + if (m.type === "agent" && typeof m.id === "string") return m.id; + } + throw new UserError("sync.multiagent.member.unresolved: roster member is not a representable agent reference."); +} diff --git a/packages/sdk/src/internal/multiagent/topology.ts b/packages/sdk/src/internal/multiagent/topology.ts new file mode 100644 index 0000000..989db07 --- /dev/null +++ b/packages/sdk/src/internal/multiagent/topology.ts @@ -0,0 +1,59 @@ +import type { DiagnosticCollector } from "../diagnostics/diagnostics.ts"; +import type { ProjectConfig } from "../types/config.ts"; + +/** Qoder and Bailian both cap a coordinator roster at 20 members. */ +export const MULTIAGENT_MEMBER_LIMIT = 20; + +/** + * Phase-1 topology rules for `multiagent` rosters: no nested coordinators and no + * cycles (direct or indirect). Members must be plain worker agents. + * + * Diagnostics are emitted in a deterministic order (sorted agent names, roster + * order preserved within an agent) so output never depends on object traversal + * order. + */ +export function collectMultiagentTopologyDiagnostics(config: ProjectConfig, diagnostics: DiagnosticCollector): void { + const agents = config.agents ?? {}; + const names = Object.keys(agents).sort(); + + for (const name of names) { + const decl = agents[name]; + if (!decl?.multiagent) continue; + for (const member of decl.multiagent.agents) { + if (typeof member !== "string") continue; + if (agents[member]?.multiagent) { + diagnostics.error( + "config.agent.multiagent.nested", + `agent.${name}: multiagent member '${member}' is itself a coordinator; nested coordinators are not supported`, + ); + } + } + } + + const state = new Map(); + const path: string[] = []; + + const visit = (name: string): void => { + if (state.get(name) === "done") return; + state.set(name, "visiting"); + path.push(name); + const members = agents[name]?.multiagent?.agents ?? []; + for (const member of members) { + if (typeof member !== "string") continue; + if (!agents[member]) continue; + if (state.get(member) === "visiting") { + const cycle = [...path.slice(path.indexOf(member)), member]; + diagnostics.error( + "config.agent.multiagent.cycle", + `agent.${name}: multiagent cycle detected: ${cycle.join(" -> ")}`, + ); + continue; + } + visit(member); + } + path.pop(); + state.set(name, "done"); + }; + + for (const name of names) visit(name); +} diff --git a/packages/sdk/src/internal/parser/schema.ts b/packages/sdk/src/internal/parser/schema.ts index 27d9f03..f1e3632 100644 --- a/packages/sdk/src/internal/parser/schema.ts +++ b/packages/sdk/src/internal/parser/schema.ts @@ -219,10 +219,26 @@ const toolsSchema = z } }); -const multiagentSchema = z.object({ - type: z.literal("coordinator"), - agents: z.array(z.string()), -}); +const multiagentSchema = z + .object({ + type: z.literal("coordinator"), + agents: z.array(z.union([z.string().min(1), z.object({ agent_id: z.string().trim().min(1) })])).min(1), + }) + .superRefine((multiagent, ctx) => { + const seen = new Set(); + for (const [index, member] of multiagent.agents.entries()) { + const key = typeof member === "string" ? `logical:${member}` : `external:${member.agent_id}`; + if (seen.has(key)) { + const label = typeof member === "string" ? member : member.agent_id; + ctx.addIssue({ + code: "custom", + path: ["agents", index], + message: `duplicates member '${label}'`, + }); + } + seen.add(key); + } + }); const agentSkillRefSchema = z .object({ diff --git a/packages/sdk/src/internal/planner/comparable.ts b/packages/sdk/src/internal/planner/comparable.ts index a09e69e..37d7f63 100644 --- a/packages/sdk/src/internal/planner/comparable.ts +++ b/packages/sdk/src/internal/planner/comparable.ts @@ -1,16 +1,32 @@ +import { resolveAgentRefs, resolveTemplateRefs } from "../executor/resolver.ts"; +import type { ResolvedAgentRefs, ResolvedTemplateRefs } from "../providers/interface.ts"; import type { DriftReadAdapter } from "../providers/resource-workflow.ts"; +import type { IStateManager } from "../state/state-manager.ts"; import type { ProjectConfig } from "../types/config.ts"; import type { ResourceAddress } from "../types/state.ts"; import { contentHash } from "../utils/hash.ts"; import { getResourceDeclaration } from "./declaration.ts"; +export function resolveComparableRefs( + address: ResourceAddress, + config: ProjectConfig | undefined, + state: IStateManager | undefined, +): ResolvedAgentRefs | ResolvedTemplateRefs | undefined { + if (!state || !config) return undefined; + if (address.type === "agent") return resolveAgentRefs(address.name, config, address.provider, state); + if (address.type === "template") return resolveTemplateRefs(address.name, config, address.provider, state); + return undefined; +} + export function computeComparableDesiredHash( address: ResourceAddress, config: ProjectConfig, provider: Pick, + state?: IStateManager, ): string | undefined { const decl = getResourceDeclaration(address, config); if (!decl || !provider.normalizeDesiredResource) return undefined; - const comparable = provider.normalizeDesiredResource(address.type, address.name, decl); + const refs = resolveComparableRefs(address, config, state); + const comparable = provider.normalizeDesiredResource(address.type, address.name, decl, refs); return comparable === null ? undefined : contentHash(comparable); } diff --git a/packages/sdk/src/internal/planner/refresh.ts b/packages/sdk/src/internal/planner/refresh.ts index 0c1ff57..5b724ab 100644 --- a/packages/sdk/src/internal/planner/refresh.ts +++ b/packages/sdk/src/internal/planner/refresh.ts @@ -6,6 +6,7 @@ import { emitRuntimeFeedback, type RuntimeFeedbackSink } from "../types/runtime- import type { ResourceState } from "../types/state.ts"; import { addressKey } from "../types/state.ts"; import { contentHash } from "../utils/hash.ts"; +import { resolveComparableRefs } from "./comparable.ts"; import { getResourceDeclaration } from "./declaration.ts"; import { diffChangedPaths } from "./plan-semantics.ts"; @@ -81,7 +82,12 @@ export async function refreshState( const remoteHash = contentHash(remote.comparable); const desiredComparable = decl - ? provider.normalizeDesiredResource(res.address.type, res.address.name, decl) + ? provider.normalizeDesiredResource( + res.address.type, + res.address.name, + decl, + resolveComparableRefs(res.address, options.config, state), + ) : null; const desiredComparableHash = desiredComparable === null ? undefined : contentHash(desiredComparable); // Resources created before full drift support have no comparable baseline. diff --git a/packages/sdk/src/internal/providers/ark/adapter.ts b/packages/sdk/src/internal/providers/ark/adapter.ts index 755e49f..bbf0b51 100644 --- a/packages/sdk/src/internal/providers/ark/adapter.ts +++ b/packages/sdk/src/internal/providers/ark/adapter.ts @@ -227,13 +227,18 @@ export class ArkAdapter implements ProviderAdapter { // failing the whole export on GET /skills. Existing stateful skill refs can still // be refreshed by id through getSkillInfo/findResource. if (type === "skill") return []; - return exportRemoteResources(this.client, type, { - envToDecl, - vaultToDecl, - fileToDecl, - skillToDecl, - agentToDecl, - }); + return exportRemoteResources( + this.client, + type, + { + envToDecl, + vaultToDecl, + fileToDecl, + skillToDecl, + agentToDecl, + }, + this.projectName, + ); } // Ark skills support create + get + attach only. There is no list/update/delete endpoint diff --git a/packages/sdk/src/internal/providers/ark/mapper.ts b/packages/sdk/src/internal/providers/ark/mapper.ts index 15cb284..6b16de8 100644 --- a/packages/sdk/src/internal/providers/ark/mapper.ts +++ b/packages/sdk/src/internal/providers/ark/mapper.ts @@ -1,4 +1,5 @@ import { UserError } from "../../errors.ts"; +import { reverseMultiagentDecl } from "../../multiagent/reverse-index.ts"; import type { AgentDecl, CredentialDecl, @@ -6,6 +7,7 @@ import type { EnvironmentDecl, MemoryStoreDecl, ModelSpec, + MultiagentMemberDecl, } from "../../types/config.ts"; import type { SessionEventType } from "../../types/dto.ts"; import type { ManagedSessionBindings } from "../../types/session.ts"; @@ -106,11 +108,13 @@ export function envToDecl(raw: Record): Record }) as Record; } -export function agentToDecl(raw: Record): Record { +export function agentToDecl( + raw: Record, + resolveMember?: (memberId: string) => MultiagentMemberDecl, +): Record { const tools = raw.tools as Array> | undefined; const mcpServers = raw.mcp_servers as Array> | undefined; const skills = raw.skills as Array> | undefined; - const multiagent = raw.multiagent as Record | undefined; let builtinTools: string[] | undefined; let builtinPermissions: Record | undefined; @@ -150,17 +154,6 @@ export function agentToDecl(raw: Record): Record | undefined; - if (multiagent) { - // Ark returns `agents` as AgentRef objects (`{type:"agent", id}`); normalize back - // to id strings for the agents decl. Tolerate a bare-string array defensively. - const rawAgents = multiagent.agents as Array | string> | undefined; - if (rawAgents?.length) { - const ids = rawAgents.map((a) => (typeof a === "string" ? a : (a.id as string))).filter(Boolean); - if (ids.length) multiagentDecl = { type: "coordinator", agents: ids }; - } - } - let toolsDecl: Record | undefined; if (builtinTools?.length) { toolsDecl = { builtin: builtinTools, permissions: builtinPermissions }; @@ -178,7 +171,7 @@ export function agentToDecl(raw: Record): Record; } @@ -285,12 +278,12 @@ export function mapAgent( } // Multiagent - if (decl.multiagent && refs.multiagent_agent_ids?.length) { + if (decl.multiagent && refs.multiagent) { // Ark's `multiagent.agents` requires `AgentRef` objects (`{type:"agent", id}`), // not a bare id string array (bare strings → 400 "Mismatch type agent.AgentRef"). body.multiagent = { type: "coordinator", - agents: refs.multiagent_agent_ids.map((id) => ({ type: "agent", id })), + agents: refs.multiagent.members.map(({ remote_id }) => ({ type: "agent", id: remote_id })), }; } diff --git a/packages/sdk/src/internal/providers/bailian/adapter.ts b/packages/sdk/src/internal/providers/bailian/adapter.ts index 656c688..6f9131b 100644 --- a/packages/sdk/src/internal/providers/bailian/adapter.ts +++ b/packages/sdk/src/internal/providers/bailian/adapter.ts @@ -12,6 +12,7 @@ import { skillStatusFromString, } from "../../../scan-lifecycle.ts"; import { UserError } from "../../errors.ts"; +import { comparableMultiagentField } from "../../multiagent/comparable.ts"; import type { AgentDecl, CredentialDecl, @@ -158,7 +159,7 @@ export class BailianAdapter implements ProviderAdapter { }; } - normalizeDesiredResource(type: ResourceType, name: string, decl: unknown): unknown | null { + normalizeDesiredResource(type: ResourceType, name: string, decl: unknown, refs?: ResolvedAgentRefs): unknown | null { if (type === "environment") { return this.normalizeRemote( type, @@ -168,7 +169,10 @@ export class BailianAdapter implements ProviderAdapter { if (type === "agent") { return this.normalizeRemote( type, - mapAgent(name, decl as AgentDecl, { skill_ids: [] }, undefined, this.projectName) as Record, + mapAgent(name, decl as AgentDecl, refs ?? { skill_ids: [] }, undefined, this.projectName) as Record< + string, + unknown + >, ); } return null; @@ -195,18 +199,24 @@ export class BailianAdapter implements ProviderAdapter { instructions: raw.system, tools: normalizeBailianTools(raw.tools), mcp_servers: normalizeBailianMcpServers(raw.mcp_servers), + multiagent: comparableMultiagentField(raw.multiagent, "managed"), metadata: stripAgentsMetadata(raw.metadata), }); } async exportResources(type: ResourceType): Promise { - return exportRemoteResources(this.client, type, { - envToDecl, - vaultToDecl, - fileToDecl, - skillToDecl, - agentToDecl, - }); + return exportRemoteResources( + this.client, + type, + { + envToDecl, + vaultToDecl, + fileToDecl, + skillToDecl, + agentToDecl, + }, + this.projectName, + ); } // --- Environment --- @@ -245,7 +255,7 @@ export class BailianAdapter implements ProviderAdapter { async createAgent(name: string, decl: AgentDecl, refs: ResolvedAgentRefs): Promise { const skillVersions = await this.fetchSkillVersions(refs); - const body = mapAgent(name, decl, refs, undefined, this.projectName, skillVersions); + const body = mapAgent(name, decl, refs, undefined, this.projectName, skillVersions, "create"); const res = (await this.client.post("/agents", body)) as Record; return toRemoteResource(res); } @@ -255,7 +265,7 @@ export class BailianAdapter implements ProviderAdapter { version: number; }; const skillVersions = await this.fetchSkillVersions(refs); - const body = mapAgent(name, decl, refs, current.version, this.projectName, skillVersions); + const body = mapAgent(name, decl, refs, current.version, this.projectName, skillVersions, "update"); const res = (await this.client.post(`/agents/${id}`, body)) as Record; return toRemoteResource(res); } diff --git a/packages/sdk/src/internal/providers/bailian/capabilities.ts b/packages/sdk/src/internal/providers/bailian/capabilities.ts index 547a39d..edef3b8 100644 --- a/packages/sdk/src/internal/providers/bailian/capabilities.ts +++ b/packages/sdk/src/internal/providers/bailian/capabilities.ts @@ -11,11 +11,7 @@ export const BAILIAN_CAPABILITIES: ProviderCapabilities = { reason: "no memory store primitive on Bailian", }, mcp_server: { tier: "native", reason: "mcp_servers field on agent (official servers)" }, - multiagent: { - tier: "unsupported", - reason: "no multiagent primitive on Bailian", - remediation: "deploy agents independently and orchestrate via MCP", - }, + multiagent: { tier: "native", reason: "coordinator + roster topology" }, deployment: { tier: "native", reason: "deployments API with cron schedules, manual runs, pause/unpause and archive", diff --git a/packages/sdk/src/internal/providers/bailian/mapper.ts b/packages/sdk/src/internal/providers/bailian/mapper.ts index 462c234..42fc3de 100644 --- a/packages/sdk/src/internal/providers/bailian/mapper.ts +++ b/packages/sdk/src/internal/providers/bailian/mapper.ts @@ -1,4 +1,5 @@ import { UserError } from "../../errors.ts"; +import { reverseMultiagentDecl } from "../../multiagent/reverse-index.ts"; import type { AgentDecl, CredentialDecl, @@ -6,13 +7,14 @@ import type { EnvironmentDecl, InitialEventDecl, ModelSpec, + MultiagentMemberDecl, VaultDecl, } from "../../types/config.ts"; import type { ManagedSessionBindings } from "../../types/session.ts"; import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts"; import { resolveSandboxMountPath } from "../../utils/sandbox-mount.ts"; import { resolveBuiltinTools } from "../../utils/tool-permissions.ts"; -import type { ResolvedAgentRefs, ResolvedDeploymentRefs } from "../interface.ts"; +import type { MappingOperation, ResolvedAgentRefs, ResolvedDeploymentRefs } from "../interface.ts"; import { injectMetadata, secretPlaceholder } from "../sync-mapping.ts"; export function mapEnvironment(name: string, decl: EnvironmentDecl, projectName?: string): unknown { @@ -157,7 +159,10 @@ export function envToDecl(raw: Record): Record } /** Reverse-map a remote agent into an AgentDecl-shaped object for agents.yaml. */ -export function agentToDecl(raw: Record): Record { +export function agentToDecl( + raw: Record, + resolveMember?: (memberId: string) => MultiagentMemberDecl, +): Record { const tools = raw.tools as Array> | undefined; const mcpServers = raw.mcp_servers as Array> | undefined; const skills = raw.skills as Array> | undefined; @@ -206,6 +211,7 @@ export function agentToDecl(raw: Record): Record; } @@ -217,6 +223,7 @@ export function mapAgent( version?: number, projectName?: string, skillVersions?: Record, + operation: MappingOperation = "create", ): unknown { let modelId: string; if (typeof decl.model === "string") { @@ -303,6 +310,17 @@ export function mapAgent( version: s.version ?? skillVersions?.[s.skill_id] ?? "1.0", })); + if (refs.multiagent) { + body.multiagent = { + type: "coordinator", + // No version: Bailian resolves the child Thread on first creation. + agents: refs.multiagent.members.map(({ remote_id }) => ({ type: "agent", id: remote_id })), + }; + } else if (operation === "update") { + // Bailian updates replace the field wholesale; an empty roster clears it. + body.multiagent = { type: "coordinator", agents: [] }; + } + return body; } diff --git a/packages/sdk/src/internal/providers/claude/adapter.ts b/packages/sdk/src/internal/providers/claude/adapter.ts index e160891..106d9a4 100644 --- a/packages/sdk/src/internal/providers/claude/adapter.ts +++ b/packages/sdk/src/internal/providers/claude/adapter.ts @@ -216,13 +216,18 @@ export class ClaudeAdapter implements ProviderAdapter { } async exportResources(type: ResourceType): Promise { - return exportRemoteResources(this.client, type, { - envToDecl, - vaultToDecl, - fileToDecl, - skillToDecl, - agentToDecl, - }); + return exportRemoteResources( + this.client, + type, + { + envToDecl, + vaultToDecl, + fileToDecl, + skillToDecl, + agentToDecl, + }, + this.projectName, + ); } async createSkill(name: string, _decl: SkillDecl, files: SkillFile[]): Promise { diff --git a/packages/sdk/src/internal/providers/claude/mapper.ts b/packages/sdk/src/internal/providers/claude/mapper.ts index 3d43a14..0cf3c10 100644 --- a/packages/sdk/src/internal/providers/claude/mapper.ts +++ b/packages/sdk/src/internal/providers/claude/mapper.ts @@ -1,4 +1,5 @@ import { UserError } from "../../errors.ts"; +import { reverseMultiagentDecl } from "../../multiagent/reverse-index.ts"; import type { AgentDecl, CredentialDecl, @@ -6,6 +7,7 @@ import type { EnvironmentDecl, InitialEventDecl, ModelSpec, + MultiagentMemberDecl, } from "../../types/config.ts"; import type { SessionEventType } from "../../types/dto.ts"; import type { ManagedSessionBindings } from "../../types/session.ts"; @@ -119,11 +121,13 @@ export function envToDecl(raw: Record): Record } /** Reverse-map a remote agent into an AgentDecl-shaped object for agents.yaml. */ -export function agentToDecl(raw: Record): Record { +export function agentToDecl( + raw: Record, + resolveMember?: (memberId: string) => MultiagentMemberDecl, +): Record { const tools = raw.tools as Array> | undefined; const mcpServers = raw.mcp_servers as Array> | undefined; const skills = raw.skills as Array> | undefined; - const multiagent = raw.multiagent as Record | undefined; // Reverse-map tools: extract builtin names from agent_toolset_20260401 configs let builtinTools: string[] | undefined; @@ -167,12 +171,6 @@ export function agentToDecl(raw: Record): Record | undefined; - if (multiagent && (multiagent.agents as string[] | undefined)?.length) { - multiagentDecl = { type: "coordinator", agents: multiagent.agents }; - } - // Resolve tools declaration let toolsDecl: Record | undefined; if (builtinTools?.length) { @@ -191,7 +189,7 @@ export function agentToDecl(raw: Record): Record; } @@ -293,10 +291,10 @@ export function mapAgent( } // Multiagent - if (decl.multiagent && refs.multiagent_agent_ids?.length) { + if (decl.multiagent && refs.multiagent) { body.multiagent = { type: "coordinator", - agents: refs.multiagent_agent_ids, + agents: refs.multiagent.members.map((member) => member.remote_id), }; } diff --git a/packages/sdk/src/internal/providers/interface.ts b/packages/sdk/src/internal/providers/interface.ts index 1c53f64..16d890e 100644 --- a/packages/sdk/src/internal/providers/interface.ts +++ b/packages/sdk/src/internal/providers/interface.ts @@ -1,3 +1,4 @@ +import type { ResolvedMultiagentRoster } from "../multiagent/model.ts"; import type { AgentDecl, ChannelDecl, @@ -104,9 +105,16 @@ export interface ExportedResource { export interface ResolvedAgentRefs { skill_ids: Array<{ type: string; skill_id: string; version?: string }>; - multiagent_agent_ids?: string[]; + multiagent?: ResolvedMultiagentRoster; } +/** + * Which provider request a mapped body is destined for. Providers with + * merge-style update APIs need it to emit explicit "clear" sentinels + * (e.g. `multiagent: null`) that a create body must omit. + */ +export type MappingOperation = "create" | "update"; + export interface ResolvedTemplateRefs extends ResolvedAgentRefs { environment_id: string; /** Qoder BYOC private-network route used by Forward Templates. */ @@ -227,7 +235,17 @@ export interface ProviderAdapter { name: string, decl?: unknown, ): Promise; - normalizeDesiredResource?(type: ResourceType, name: string, decl: unknown): unknown | null; + /** + * Canonical desired form for drift comparison. `refs` carries resolved + * skill and multiagent references; callers only compute it for agent/template + * resources where they have config + state at hand. + */ + normalizeDesiredResource?( + type: ResourceType, + name: string, + decl: unknown, + refs?: ResolvedAgentRefs | ResolvedTemplateRefs, + ): unknown | null; /** * Reverse-map remote resources of a given type into `agents.yaml` declarations diff --git a/packages/sdk/src/internal/providers/qoder/adapter.ts b/packages/sdk/src/internal/providers/qoder/adapter.ts index 17770bf..4532a27 100644 --- a/packages/sdk/src/internal/providers/qoder/adapter.ts +++ b/packages/sdk/src/internal/providers/qoder/adapter.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { basename, dirname, resolve } from "node:path"; import JSZip from "jszip"; import { UserError } from "../../errors.ts"; +import { comparableMultiagentField } from "../../multiagent/comparable.ts"; import type { AgentDecl, ChannelDecl, @@ -380,7 +381,12 @@ export class QoderAdapter implements ProviderAdapter { ); } - normalizeDesiredResource(type: ResourceType, name: string, decl: unknown): unknown | null { + normalizeDesiredResource( + type: ResourceType, + name: string, + decl: unknown, + refs?: ResolvedAgentRefs | ResolvedTemplateRefs, + ): unknown | null { if (type === "environment") { return this.normalizeRemote( type, @@ -390,10 +396,22 @@ export class QoderAdapter implements ProviderAdapter { if (type === "agent") { return this.normalizeRemote( type, - mapAgent(name, decl as AgentDecl, { skill_ids: [] }, undefined, this.projectName) as Record, + mapAgent(name, decl as AgentDecl, refs ?? { skill_ids: [] }, undefined, this.projectName) as Record< + string, + unknown + >, + ); + } + if (type === "template") { + if (!refs) return null; + return this.normalizeRemote( + type, + mapForwardTemplate(name, decl as AgentDecl, refs as ResolvedTemplateRefs, this.projectName) as Record< + string, + unknown + >, ); } - if (type === "template") return null; if (type === "identity") { const identity = decl as IdentityDecl; if (identity.identity_id) return null; @@ -422,10 +440,10 @@ export class QoderAdapter implements ProviderAdapter { description: raw.description, model: raw.model, system: raw.system, - tools: raw.tools, - mcp_servers: raw.mcp_servers, + tools: normalizeQoderTools(raw.tools), + mcp_servers: normalizeQoderMcpServers(raw.mcp_servers), skills: raw.skills, - multiagent: raw.multiagent, + multiagent: comparableMultiagentField(raw.multiagent, "forward"), environment_id: raw.environment_id, tunnel_id: raw.tunnel_id, vault_ids: Array.isArray(raw.vault_ids) @@ -470,6 +488,7 @@ export class QoderAdapter implements ProviderAdapter { instructions: raw.system, tools: normalizeQoderTools(raw.tools), mcp_servers: normalizeQoderMcpServers(raw.mcp_servers), + multiagent: comparableMultiagentField(raw.multiagent, "managed"), metadata: stripAgentsMetadata(raw.metadata), }); } @@ -598,13 +617,18 @@ export class QoderAdapter implements ProviderAdapter { } async exportResources(type: ResourceType): Promise { - return exportRemoteResources(this.client, type, { - envToDecl, - vaultToDecl, - fileToDecl, - skillToDecl, - agentToDecl, - }); + return exportRemoteResources( + this.client, + type, + { + envToDecl, + vaultToDecl, + fileToDecl, + skillToDecl, + agentToDecl, + }, + this.projectName, + ); } async createSkill( @@ -641,7 +665,7 @@ export class QoderAdapter implements ProviderAdapter { } async createAgent(name: string, decl: AgentDecl, refs: ResolvedAgentRefs): Promise { - const body = mapAgent(name, decl, refs, undefined, this.projectName); + const body = mapAgent(name, decl, refs, undefined, this.projectName, "create"); const res = (await this.client.post("/agents", body)) as Record; return toRemoteResource(res); } @@ -650,7 +674,7 @@ export class QoderAdapter implements ProviderAdapter { const current = (await this.client.get(`/agents/${id}`)) as { version: number; }; - const body = mapAgent(name, decl, refs, current.version, this.projectName); + const body = mapAgent(name, decl, refs, current.version, this.projectName, "update"); const res = (await this.client.put(`/agents/${id}`, body)) as Record; return toRemoteResource(res); } @@ -660,14 +684,14 @@ export class QoderAdapter implements ProviderAdapter { } async createTemplate(name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise { - const body = mapForwardTemplate(name, decl, refs, this.projectName); + const body = mapForwardTemplate(name, decl, refs, this.projectName, "create"); const res = (await this.forwardClient.post("/templates", body)) as Record; await this.reconcileForwardMemoryMounts(res.id as string, refs); return toRemoteResource(res); } async updateTemplate(id: string, name: string, decl: AgentDecl, refs: ResolvedTemplateRefs): Promise { - const body = mapForwardTemplate(name, decl, refs, this.projectName) as Record; + const body = mapForwardTemplate(name, decl, refs, this.projectName, "update") as Record; // Forward updates are merge-style; null explicitly clears a previously inherited BYOC tunnel. if (!refs.tunnel_id) body.tunnel_id = null; const res = (await this.forwardClient.post(`/templates/${id}`, body)) as Record; diff --git a/packages/sdk/src/internal/providers/qoder/capabilities.ts b/packages/sdk/src/internal/providers/qoder/capabilities.ts index 8d2b49e..3d02c59 100644 --- a/packages/sdk/src/internal/providers/qoder/capabilities.ts +++ b/packages/sdk/src/internal/providers/qoder/capabilities.ts @@ -8,11 +8,7 @@ export const QODER_CAPABILITIES: ProviderCapabilities = { template: { tier: "native", reason: "Forward Templates API" }, memory_store: { tier: "native", reason: "memory_stores API" }, mcp_server: { tier: "native", reason: "mcp_servers field on agent" }, - multiagent: { - tier: "unsupported", - reason: "no multiagent primitive on Qoder", - remediation: "deploy agents independently and orchestrate via MCP", - }, + multiagent: { tier: "native", reason: "coordinator + roster topology" }, deployment: { tier: "native", reason: "deployments API with scheduled and manual runs", diff --git a/packages/sdk/src/internal/providers/qoder/mapper.ts b/packages/sdk/src/internal/providers/qoder/mapper.ts index 3bf2113..9429bc1 100644 --- a/packages/sdk/src/internal/providers/qoder/mapper.ts +++ b/packages/sdk/src/internal/providers/qoder/mapper.ts @@ -1,4 +1,5 @@ import { UserError } from "../../errors.ts"; +import { reverseMultiagentDecl } from "../../multiagent/reverse-index.ts"; import type { AgentDecl, CredentialDecl, @@ -7,6 +8,7 @@ import type { InitialEventDecl, MemoryStoreDecl, ModelSpec, + MultiagentMemberDecl, VaultDecl, } from "../../types/config.ts"; import type { SessionEventType } from "../../types/dto.ts"; @@ -15,7 +17,12 @@ import type { ProviderSessionEvent } from "../../types/session-event.ts"; import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts"; import { resolveSandboxMountPath } from "../../utils/sandbox-mount.ts"; import { permissionOverridesFromWire, resolveBuiltinTools, toPermissionPolicy } from "../../utils/tool-permissions.ts"; -import type { ResolvedAgentRefs, ResolvedDeploymentRefs, ResolvedTemplateRefs } from "../interface.ts"; +import type { + MappingOperation, + ResolvedAgentRefs, + ResolvedDeploymentRefs, + ResolvedTemplateRefs, +} from "../interface.ts"; import { mapGithubRepositorySessionResource, resolveGithubRepositoryMountPath } from "../session-resource-mapper.ts"; import { injectManagedResourceMetadata, injectMetadata, secretPlaceholder, slug } from "../sync-mapping.ts"; @@ -196,7 +203,10 @@ export function skillToDecl(raw: Record, name: string): Record< } /** Reverse-map a remote agent into an AgentDecl-shaped object for agents.yaml. */ -export function agentToDecl(raw: Record): Record { +export function agentToDecl( + raw: Record, + resolveMember?: (memberId: string) => MultiagentMemberDecl, +): Record { const tools = raw.tools as Array> | undefined; const mcpServers = raw.mcp_servers as Array> | undefined; const skills = raw.skills as Array> | undefined; @@ -247,6 +257,7 @@ export function agentToDecl(raw: Record): Record; } @@ -401,6 +412,7 @@ export function mapAgent( refs: ResolvedAgentRefs, version?: number, projectName?: string, + operation: MappingOperation = "create", ): unknown { let model: string; if (typeof decl.model === "string") { @@ -473,6 +485,16 @@ export function mapAgent( skill_id: s.skill_id, })); + if (refs.multiagent) { + body.multiagent = { + type: "coordinator", + agents: refs.multiagent.members.map(({ remote_id }) => ({ type: "agent", id: remote_id })), + }; + } else if (operation === "update") { + // Qoder updates are merge-style; null is the only way to clear a roster. + body.multiagent = null; + } + return body; } @@ -482,6 +504,7 @@ export function mapForwardTemplate( decl: AgentDecl, refs: ResolvedTemplateRefs, projectName?: string, + operation: MappingOperation = "create", ): unknown { let model: string; if (typeof decl.model === "string") { @@ -551,6 +574,16 @@ export function mapForwardTemplate( enabled: true, })); + if (refs.multiagent) { + body.multiagent = { + type: "coordinator", + agents: refs.multiagent.members.map(({ remote_id }) => ({ type: "agent", template_id: remote_id })), + }; + } else if (operation === "update") { + // Forward updates are merge-style; null is the only way to clear a roster. + body.multiagent = null; + } + return body; } diff --git a/packages/sdk/src/internal/providers/resource-workflow.ts b/packages/sdk/src/internal/providers/resource-workflow.ts index 21f5e33..cd24c0b 100644 --- a/packages/sdk/src/internal/providers/resource-workflow.ts +++ b/packages/sdk/src/internal/providers/resource-workflow.ts @@ -161,5 +161,10 @@ export interface DriftReadAdapter { name: string, decl?: unknown, ): Promise; - normalizeDesiredResource?(type: ResourceType, name: string, decl: unknown): unknown | null; + normalizeDesiredResource?( + type: ResourceType, + name: string, + decl: unknown, + refs?: ResolvedAgentRefs | ResolvedTemplateRefs, + ): unknown | null; } diff --git a/packages/sdk/src/internal/providers/shared.ts b/packages/sdk/src/internal/providers/shared.ts index a629838..f89e85a 100644 --- a/packages/sdk/src/internal/providers/shared.ts +++ b/packages/sdk/src/internal/providers/shared.ts @@ -1,3 +1,5 @@ +import { buildReverseIndex, memberDeclResolver } from "../multiagent/reverse-index.ts"; +import type { MultiagentMemberDecl } from "../types/config.ts"; import type { CloudAgent, CloudEnvironment, CloudVault } from "../types/dto.ts"; import type { ProviderFileInfo } from "../types/file.ts"; import type { ProviderSessionInfo } from "../types/session.ts"; @@ -86,7 +88,10 @@ export interface ExportMappers { ) => Record; fileToDecl: (raw: Record, filename: string) => Record; skillToDecl: (raw: Record, name: string) => Record; - agentToDecl: (raw: Record) => Record; + agentToDecl: ( + raw: Record, + resolveMember?: (memberId: string) => MultiagentMemberDecl, + ) => Record; } /** @@ -101,6 +106,7 @@ export async function exportRemoteResources( client: BaseApiClient, type: ResourceType, mappers: ExportMappers, + projectName?: string, ): Promise { if (type === "environment") { const envs = await client.getAllPaged("/environments"); @@ -140,11 +146,16 @@ export async function exportRemoteResources( } if (type === "agent") { const agents = await client.getAllPaged("/agents"); + // The reverse index needs the FULL listing — archived entries included — so an + // archived roster member fails with `archived` rather than `unresolved`; + // archived agents stay excluded from the exported resources themselves. + const resolveMember = memberDeclResolver(buildReverseIndex(agents, projectName ?? "")); return agents .filter((agent) => !agent.archived_at) .map((agent) => { const agentId = agent.id as string; - return { name: agentId, decl: mappers.agentToDecl(agent) }; + const name = resourceNameFromMetadata(agent.metadata, (agent.name as string) ?? agentId, agentId); + return { name, decl: mappers.agentToDecl(agent, resolveMember) }; }); } return []; diff --git a/packages/sdk/src/internal/types/config.ts b/packages/sdk/src/internal/types/config.ts index 6366fcf..519a898 100644 --- a/packages/sdk/src/internal/types/config.ts +++ b/packages/sdk/src/internal/types/config.ts @@ -272,9 +272,12 @@ export interface McpServerDecl { export interface MultiagentDecl { type: "coordinator"; - agents: string[]; + agents: MultiagentMemberDecl[]; } +/** A project-owned logical Agent name, or an externally owned Managed Agent id. */ +export type MultiagentMemberDecl = string | { agent_id: string }; + // --- Deployment --- export interface DeploymentDecl { diff --git a/packages/sdk/tests/e2e/multiagent-live.ts b/packages/sdk/tests/e2e/multiagent-live.ts new file mode 100644 index 0000000..042183c --- /dev/null +++ b/packages/sdk/tests/e2e/multiagent-live.ts @@ -0,0 +1,550 @@ +// Explicit opt-in Multi-Agent live probe (implementation plan step 10). +// +// Usage: +// bun packages/sdk/tests/e2e/multiagent-live.ts qoder-managed +// bun packages/sdk/tests/e2e/multiagent-live.ts qoder-forward +// bun packages/sdk/tests/e2e/multiagent-live.ts bailian +// +// One external worker + one coordinator per Managed run. Verifies that `{agent_id}` +// resolves without local state, then create → read (roster) → run → clear → cleanup. +// Qoder Forward retains its project-logical-name coverage because `{agent_id}` is +// intentionally unsupported there. All temporary resources are +// named `oap-ma-live-${timestamp}*` and deleted/archived in `finally`. Logs never +// include tokens, full user metadata, or listings of non-test resources. + +import { resolveAgentRefs, resolveTemplateRefs } from "../../src/internal/executor/resolver.ts"; +import { BailianAdapter } from "../../src/internal/providers/bailian/adapter.ts"; +import type { ComparableRemoteResource, ProviderAdapter } from "../../src/internal/providers/interface.ts"; +import { QoderAdapter } from "../../src/internal/providers/qoder/adapter.ts"; +import type { IStateManager } from "../../src/internal/state/state-manager.ts"; +import { StateManager } from "../../src/internal/state/state-manager.ts"; +import type { AgentDecl, EnvironmentDecl, ProjectConfig } from "../../src/internal/types/config.ts"; +import type { ProviderSessionEvent } from "../../src/internal/types/session-event.ts"; + +const MODE = process.argv[2]; +const MODES = ["qoder-managed", "qoder-forward", "bailian"] as const; +type Mode = (typeof MODES)[number]; + +if (!MODE || !MODES.includes(MODE as Mode)) { + console.log(`Usage: bun packages/sdk/tests/e2e/multiagent-live.ts <${MODES.join("|")}>`); + process.exit(2); +} + +const ts = Date.now().toString(36); +const projectName = `oap-ma-live-${ts}`; +const workerName = `${projectName}-worker`; +const coordName = `${projectName}-coord`; +const envName = `${projectName}-env`; +const identityName = `${projectName}-identity`; + +const QODER_PAT = process.env.QODER_PAT; +const DASHSCOPE_API_KEY = process.env.DASHSCOPE_API_KEY; +const BAILIAN_WORKSPACE_ID = process.env.BAILIAN_WORKSPACE_ID; +const BAILIAN_BASE_URL = process.env.BAILIAN_BASE_URL?.trim() || undefined; + +if (MODE.startsWith("qoder") && !QODER_PAT) { + console.log("⏭ QODER_PAT not set; skipping qoder multiagent live probe"); + process.exit(0); +} +if (MODE === "bailian" && (!DASHSCOPE_API_KEY || !BAILIAN_WORKSPACE_ID)) { + console.log("⏭ DASHSCOPE_API_KEY or BAILIAN_WORKSPACE_ID not set; skipping bailian multiagent live probe"); + process.exit(0); +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(`assertion failed: ${message}`); +} + +function errText(err: unknown): string { + return err instanceof Error ? err.message.slice(0, 200) : String(err).slice(0, 200); +} + +interface ProbeState { + sessionId?: string; + coordId?: string; + workerId?: string; + envId?: string; + identityId?: string; +} + +const probe: ProbeState = {}; + +function setState( + state: IStateManager, + type: "agent" | "template" | "environment", + name: string, + remoteId: string, +): void { + state.setResource({ + address: { provider: "qoder", type, name }, + remote_id: remoteId, + ...(type === "environment" ? { api_mode: "forward" as const } : {}), + content_hash: "", + }); +} + +function rosterOf(remote: ComparableRemoteResource | null): unknown { + assert(remote, "readComparableResource returned null for the coordinator"); + return (remote.comparable as { multiagent?: unknown }).multiagent; +} + +async function readRoster( + adapter: ProviderAdapter, + type: "agent" | "template", + id: string, + name: string, + decl?: AgentDecl, +): Promise { + const remote = await adapter.readComparableResource(type, id, name, decl); + return rosterOf(remote); +} + +function assertRosterEqual(actual: unknown, memberRemoteId: string, label: string): void { + const want = JSON.stringify({ type: "coordinator", member_ids: [memberRemoteId] }); + const got = JSON.stringify(actual ?? null); + assert(got === want, `${label}: roster mismatch — got ${got}, want ${want}`); +} + +interface TurnSummary { + status: string; + eventCounts: Record; + threadedEvents: number; + sawAssistantMessage: boolean; + sawError: boolean; +} + +function summarizeEvents(events: ProviderSessionEvent[]): { + counts: Record; + threaded: number; + sawAssistantMessage: boolean; + sawError: boolean; +} { + const counts: Record = {}; + let threaded = 0; + let sawAssistantMessage = false; + let sawError = false; + for (const e of events) { + const key = e.raw_type || e.type; + counts[key] = (counts[key] ?? 0) + 1; + if (e.session_thread_id) threaded += 1; + if (e.type === "message" && e.raw_type !== "user.message" && e.role !== "user") sawAssistantMessage = true; + if (e.type === "error") sawError = true; + } + return { counts, threaded, sawAssistantMessage, sawError }; +} + +async function runTurn( + adapter: ProviderAdapter, + sessionId: string, + prompt: string, + timeoutMs: number, +): Promise { + await adapter.sendSessionMessage(sessionId, prompt); + const deadline = Date.now() + timeoutMs; + let summary: TurnSummary = { + status: "unknown", + eventCounts: {}, + threadedEvents: 0, + sawAssistantMessage: false, + sawError: false, + }; + let prevCount = -1; + while (Date.now() < deadline) { + await Bun.sleep(3000); + const session = await adapter.getSession(sessionId); + const listed = await adapter.listSessionEvents(sessionId, { limit: 100 }); + const { counts, threaded, sawAssistantMessage, sawError } = summarizeEvents(listed.events); + summary = { status: session.status, eventCounts: counts, threadedEvents: threaded, sawAssistantMessage, sawError }; + const settled = (sawAssistantMessage || sawError) && listed.events.length === prevCount; + if (settled || session.status === "terminated") break; + prevCount = listed.events.length; + } + return summary; +} + +async function waitForIdle(adapter: ProviderAdapter, id: string, timeoutMs = 60_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const s = await adapter.getSession(id); + if (s.status === "idle" || s.status === "terminated") return; + await Bun.sleep(2000); + } + console.log(` ⚠ session ${id} did not reach idle within ${timeoutMs}ms`); +} + +async function safeDeleteSession(adapter: ProviderAdapter, id: string): Promise { + try { + await adapter.deleteSession(id); + return; + } catch { + // Retry after waiting for the turn to finish. + } + try { + await waitForIdle(adapter, id); + await adapter.deleteSession(id); + } catch (err) { + console.log(` ⚠ session cleanup failed for ${id}: ${errText(err)}`); + } +} + +function baseAgentDecl(instructions: string): AgentDecl { + return { + model: "ultimate", + instructions, + description: "oap multiagent live probe", + tools: { builtin: ["Read", "Glob", "Grep"] }, + }; +} + +function forwardAgentDecl(instructions: string, environment: string): AgentDecl { + return { ...baseAgentDecl(instructions), environment, delivery: { qoder: { type: "forward" } } }; +} + +const ENV_DECL: EnvironmentDecl = { + description: "oap multiagent live probe environment", + config: { type: "cloud", networking: { type: "unrestricted" } }, +}; + +// --------------------------------------------------------------------------- +// qoder-managed +// --------------------------------------------------------------------------- + +async function runQoderManaged(): Promise { + const adapter = new QoderAdapter(QODER_PAT!, undefined, projectName); + const state = StateManager.initialize(`/tmp/${projectName}-state.json`); + + const workerDecl = baseAgentDecl("You are a live-probe worker. Answer questions with one short sentence."); + const workerConfig: ProjectConfig = { + version: "1", + providers: { qoder: {} }, + agents: { [workerName]: workerDecl }, + }; + + console.log("1. validate()..."); + await adapter.validate(); + console.log(" ✅ validate passed\n"); + + console.log("2. createEnvironment()..."); + const env = await adapter.createEnvironment(envName, ENV_DECL); + probe.envId = env.id!; + console.log(` ✅ created environment: ${env.id}\n`); + + console.log("3. createAgent() external worker..."); + const workerRefs = resolveAgentRefs(workerName, workerConfig, "qoder", state); + const worker = await adapter.createAgent(workerName, workerDecl, workerRefs); + probe.workerId = worker.id!; + console.log(` ✅ created external worker agent: ${worker.id} (not written to local state)\n`); + + const coordDecl: AgentDecl = { + ...baseAgentDecl( + "You are a live-probe coordinator with one external worker. Delegate the user's question to the worker, then return its answer verbatim.", + ), + multiagent: { type: "coordinator", agents: [{ agent_id: worker.id! }] }, + }; + const config: ProjectConfig = { + version: "1", + providers: { qoder: {} }, + agents: { [coordName]: coordDecl }, + }; + const clearedConfig: ProjectConfig = { + version: "1", + providers: { qoder: {} }, + agents: { [coordName]: { ...coordDecl, multiagent: undefined } }, + }; + + console.log("4. createAgent() coordinator with multiagent roster..."); + const coordRefs = resolveAgentRefs(coordName, config, "qoder", state); + assert(coordRefs.multiagent, "resolveAgentRefs did not compute the multiagent roster"); + assert(coordRefs.multiagent.members.length === 1, "roster should have exactly one member"); + assert(coordRefs.multiagent.members[0]!.remote_id === worker.id, "roster member remote_id mismatch"); + assert(coordRefs.multiagent.members[0]!.logical_name === undefined, "external member must have no logical name"); + const coord = await adapter.createAgent(coordName, coordDecl, coordRefs); + probe.coordId = coord.id!; + console.log(` ✅ created coordinator agent: ${coord.id} (version=${coord.version})\n`); + + console.log("5. readComparableResource() — roster wired with member id..."); + const roster = await readRoster(adapter, "agent", coord.id!, coordName, coordDecl); + assertRosterEqual(roster, worker.id!, "managed create read-back"); + console.log(` ✅ remote roster = ${JSON.stringify(roster)}\n`); + + console.log("6. createSession() + sendSessionMessage() on the coordinator..."); + const session = await adapter.createSession({ + agent_id: coord.id!, + environment_id: env.id!, + vault_ids: [], + memory_store_ids: [], + title: `${projectName} session`, + }); + probe.sessionId = session.id; + const turn = await runTurn(adapter, session.id, "What is 2+2? Reply with just the number.", 120_000); + console.log( + ` ✅ session ${session.id}: status=${turn.status}, events=${JSON.stringify(turn.eventCounts)}, threaded=${turn.threadedEvents}\n`, + ); + assert(turn.sawAssistantMessage, "coordinator session produced no assistant message"); + + console.log("7. updateAgent() without multiagent — roster must clear (multiagent: null)..."); + const clearedRefs = resolveAgentRefs(coordName, clearedConfig, "qoder", state); + assert(!clearedRefs.multiagent, "cleared refs still carry a roster"); + await adapter.updateAgent(coord.id!, coordName, clearedConfig.agents![coordName]!, clearedRefs); + const clearedRoster = await readRoster(adapter, "agent", coord.id!, coordName, clearedConfig.agents![coordName]!); + assert(clearedRoster === undefined, `cleared roster should be absent, got ${JSON.stringify(clearedRoster ?? null)}`); + console.log(" ✅ remote roster cleared\n"); +} + +// --------------------------------------------------------------------------- +// qoder-forward +// --------------------------------------------------------------------------- + +async function runQoderForward(): Promise { + const adapter = new QoderAdapter(QODER_PAT!, undefined, projectName); + const state = StateManager.initialize(`/tmp/${projectName}-state.json`); + + const workerDecl = forwardAgentDecl( + "You are a live-probe worker. Answer questions with one short sentence.", + envName, + ); + const coordDecl: AgentDecl = { + ...forwardAgentDecl( + "You are a live-probe coordinator with one worker. Delegate the user's question to the worker, then return its answer verbatim.", + envName, + ), + multiagent: { type: "coordinator", agents: [workerName] }, + }; + const config: ProjectConfig = { + version: "1", + providers: { qoder: {} }, + environments: { [envName]: ENV_DECL }, + agents: { [workerName]: workerDecl, [coordName]: coordDecl }, + }; + const clearedConfig: ProjectConfig = { + version: "1", + providers: { qoder: {} }, + environments: { [envName]: ENV_DECL }, + agents: { [workerName]: workerDecl, [coordName]: { ...coordDecl, multiagent: undefined } }, + }; + + console.log("1. validate()..."); + await adapter.validate(); + console.log(" ✅ validate passed\n"); + + console.log("2. createIdentity()..."); + const identity = await adapter.createIdentity(identityName, { + external_id: identityName, + name: identityName, + enabled: true, + }); + probe.identityId = identity.id!; + console.log(` ✅ created identity: ${identity.id}\n`); + + console.log("3. createEnvironment(forward)..."); + const env = await adapter.createEnvironment(envName, ENV_DECL, "forward"); + probe.envId = env.id!; + setState(state, "environment", envName, env.id!); + console.log(` ✅ created forward environment: ${env.id}\n`); + + console.log("4. createTemplate() worker..."); + const workerRefs = resolveTemplateRefs(workerName, config, "qoder", state); + const worker = await adapter.createTemplate(workerName, workerDecl, workerRefs); + probe.workerId = worker.id!; + setState(state, "template", workerName, worker.id!); + console.log(` ✅ created worker template: ${worker.id}\n`); + + console.log("5. createTemplate() coordinator with multiagent roster..."); + const coordRefs = resolveTemplateRefs(coordName, config, "qoder", state); + assert(coordRefs.multiagent, "resolveTemplateRefs did not compute the multiagent roster"); + assert(coordRefs.multiagent.members[0]!.remote_id === worker.id, "roster member remote_id mismatch"); + assert(coordRefs.multiagent.members[0]!.resource_type === "template", "forward roster members must be templates"); + const coord = await adapter.createTemplate(coordName, coordDecl, coordRefs); + probe.coordId = coord.id!; + console.log(` ✅ created coordinator template: ${coord.id}\n`); + + console.log("6. readComparableResource() — roster wired with template_id..."); + const roster = await readRoster(adapter, "template", coord.id!, coordName); + assertRosterEqual(roster, worker.id!, "forward create read-back"); + console.log(` ✅ remote roster = ${JSON.stringify(roster)}\n`); + + console.log("7. createSession(forward) + sendSessionMessage() on the coordinator..."); + const session = await adapter.createSession({ + delivery: "forward", + template_id: coord.id!, + identity_id: probe.identityId, + title: `${projectName} session`, + }); + probe.sessionId = session.id; + const turn = await runTurn(adapter, session.id, "What is 2+2? Reply with just the number.", 120_000); + console.log( + ` ✅ session ${session.id}: status=${turn.status}, events=${JSON.stringify(turn.eventCounts)}, threaded=${turn.threadedEvents}\n`, + ); + assert(turn.sawAssistantMessage, "coordinator session produced no assistant message"); + + console.log("8. updateTemplate() without multiagent — roster must clear (multiagent: null)..."); + const clearedRefs = resolveTemplateRefs(coordName, clearedConfig, "qoder", state); + assert(!clearedRefs.multiagent, "cleared refs still carry a roster"); + await adapter.updateTemplate(coord.id!, coordName, clearedConfig.agents![coordName]!, clearedRefs); + const clearedRoster = await readRoster(adapter, "template", coord.id!, coordName); + assert(clearedRoster === undefined, `cleared roster should be absent, got ${JSON.stringify(clearedRoster ?? null)}`); + console.log(" ✅ remote roster cleared\n"); +} + +// --------------------------------------------------------------------------- +// bailian +// --------------------------------------------------------------------------- + +async function runBailian(): Promise { + const adapter = new BailianAdapter(DASHSCOPE_API_KEY!, BAILIAN_WORKSPACE_ID!, BAILIAN_BASE_URL, projectName); + const state = StateManager.initialize(`/tmp/${projectName}-state.json`); + + const workerDecl: AgentDecl = { + model: "qwen3.7-max", + instructions: "You are a live-probe worker. Answer questions with one short sentence.", + description: "oap multiagent live probe worker", + }; + const workerConfig: ProjectConfig = { + version: "1", + providers: { bailian: {} }, + agents: { [workerName]: workerDecl }, + }; + const envDecl: EnvironmentDecl = { + description: "oap multiagent live probe environment", + config: { type: "cloud" }, + }; + + console.log("1. validate()..."); + await adapter.validate(); + console.log(" ✅ validate passed\n"); + + console.log("2. createEnvironment()..."); + const env = await adapter.createEnvironment(envName, envDecl); + probe.envId = env.id!; + console.log(` ✅ created environment: ${env.id}\n`); + + console.log("3. createAgent() external worker..."); + const workerRefs = resolveAgentRefs(workerName, workerConfig, "bailian", state); + const worker = await adapter.createAgent(workerName, workerDecl, workerRefs); + probe.workerId = worker.id!; + console.log(` ✅ created external worker agent: ${worker.id} (not written to local state)\n`); + + const coordDecl: AgentDecl = { + model: "qwen3.7-max", + instructions: + "You are a live-probe coordinator with one external worker. Delegate the user's question to the worker, then return its answer verbatim.", + description: "oap multiagent live probe coordinator", + multiagent: { type: "coordinator", agents: [{ agent_id: worker.id! }] }, + }; + const config: ProjectConfig = { + version: "1", + providers: { bailian: {} }, + agents: { [coordName]: coordDecl }, + }; + const clearedConfig: ProjectConfig = { + version: "1", + providers: { bailian: {} }, + agents: { [coordName]: { ...coordDecl, multiagent: undefined } }, + }; + + console.log("4. createAgent() coordinator with multiagent roster..."); + const coordRefs = resolveAgentRefs(coordName, config, "bailian", state); + assert(coordRefs.multiagent, "resolveAgentRefs did not compute the multiagent roster"); + assert(coordRefs.multiagent.members[0]!.remote_id === worker.id, "roster member remote_id mismatch"); + assert(coordRefs.multiagent.members[0]!.logical_name === undefined, "external member must have no logical name"); + const coord = await adapter.createAgent(coordName, coordDecl, coordRefs); + probe.coordId = coord.id!; + console.log(` ✅ created coordinator agent: ${coord.id} (version=${coord.version})\n`); + + console.log("5. readComparableResource() — roster wired with member id..."); + const roster = await readRoster(adapter, "agent", coord.id!, coordName); + assertRosterEqual(roster, worker.id!, "bailian create read-back"); + console.log(` ✅ remote roster = ${JSON.stringify(roster)}\n`); + + console.log("6. createSession() + sendSessionMessage() on the coordinator..."); + const session = await adapter.createSession({ + agent_id: coord.id!, + environment_id: env.id!, + vault_ids: [], + memory_store_ids: [], + title: `${projectName} session`, + }); + probe.sessionId = session.id; + const turn = await runTurn(adapter, session.id, "What is 2+2? Reply with just the number.", 150_000); + console.log( + ` ✅ session ${session.id}: status=${turn.status}, events=${JSON.stringify(turn.eventCounts)}, threaded=${turn.threadedEvents}\n`, + ); + assert(turn.sawAssistantMessage, "coordinator session produced no assistant message"); + + console.log("7. updateAgent() without multiagent — roster must clear (empty coordinator)..."); + const clearedRefs = resolveAgentRefs(coordName, clearedConfig, "bailian", state); + assert(!clearedRefs.multiagent, "cleared refs still carry a roster"); + await adapter.updateAgent(coord.id!, coordName, clearedConfig.agents![coordName]!, clearedRefs); + const clearedRoster = await readRoster(adapter, "agent", coord.id!, coordName); + assert(clearedRoster === undefined, `cleared roster should be absent, got ${JSON.stringify(clearedRoster ?? null)}`); + console.log(" ✅ remote roster cleared\n"); +} + +// --------------------------------------------------------------------------- +// runner + cleanup +// --------------------------------------------------------------------------- + +async function cleanup(): Promise { + const mode = MODE as Mode; + const adapter: ProviderAdapter = + mode === "bailian" + ? new BailianAdapter(DASHSCOPE_API_KEY!, BAILIAN_WORKSPACE_ID!, BAILIAN_BASE_URL, projectName) + : new QoderAdapter(QODER_PAT!, undefined, projectName); + + console.log(`\n🧹 Cleanup (${mode}) — destroy order is the reverse of creation`); + if (probe.sessionId) { + await safeDeleteSession(adapter, probe.sessionId); + console.log(` 🧹 removed session: ${probe.sessionId}`); + } + if (probe.coordId) { + try { + if (mode === "qoder-forward") await adapter.archiveTemplate!(probe.coordId); + else await adapter.deleteAgent(probe.coordId); + console.log(` 🧹 ${mode === "qoder-forward" ? "archived" : "archived/deleted"} coordinator: ${probe.coordId}`); + } catch (err) { + console.log(` ⚠ coordinator cleanup failed for ${probe.coordId}: ${errText(err)}`); + } + } + if (probe.workerId) { + try { + if (mode === "qoder-forward") await adapter.archiveTemplate!(probe.workerId); + else await adapter.deleteAgent(probe.workerId); + console.log(` 🧹 ${mode === "qoder-forward" ? "archived" : "archived/deleted"} worker: ${probe.workerId}`); + } catch (err) { + console.log(` ⚠ worker cleanup failed for ${probe.workerId}: ${errText(err)}`); + } + } + if (probe.identityId && mode === "qoder-forward") { + try { + await adapter.deleteIdentity!(probe.identityId); + console.log(` 🧹 deleted identity: ${probe.identityId}`); + } catch (err) { + console.log(` ⚠ identity cleanup failed for ${probe.identityId}: ${errText(err)}`); + } + } + if (probe.envId) { + try { + // Qoder rejects environment deletion with "referenced by N session(s)" unless cascade=true, + // even when N=0 — the probe's sessions are already deleted by this point. + await adapter.deleteEnvironment(probe.envId, true, mode === "qoder-forward" ? "forward" : "managed"); + console.log(` 🧹 deleted environment: ${probe.envId}`); + } catch (err) { + console.log(` ⚠ environment cleanup failed for ${probe.envId}: ${errText(err)}`); + } + } +} + +async function main(): Promise { + console.log(`=== Multi-Agent Live Probe: ${MODE} (prefix ${projectName}) ===\n`); + if (MODE === "qoder-managed") await runQoderManaged(); + else if (MODE === "qoder-forward") await runQoderForward(); + else await runBailian(); + console.log(`=== Multi-Agent live probe (${MODE}) passed! ===`); +} + +main() + .catch((err) => { + console.error(`\n❌ Multi-Agent live probe (${MODE}) failed: ${errText(err)}`); + process.exitCode = 1; + }) + .finally(() => cleanup()); diff --git a/packages/sdk/tests/unit/ark-provider.test.ts b/packages/sdk/tests/unit/ark-provider.test.ts index 9e63be1..5cd3bbf 100644 --- a/packages/sdk/tests/unit/ark-provider.test.ts +++ b/packages/sdk/tests/unit/ark-provider.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { refreshState } from "../../src/internal/planner/refresh.ts"; import { ArkAdapter } from "../../src/internal/providers/ark/adapter.ts"; +import { mapAgent } from "../../src/internal/providers/ark/mapper.ts"; import { StateManager } from "../../src/internal/state/state-manager.ts"; function tmpPath(): string { @@ -81,3 +82,33 @@ describe("Ark provider platform gaps", () => { expect(JSON.parse(postedBodies[1]!).name).toBe("agents-base-1"); }); }); + +describe("Ark mapAgent multiagent regression", () => { + test("maps a resolved roster to AgentRef objects", () => { + const body = mapAgent( + "lead", + { + model: { ark: "doubao-seed-1-6" }, + instructions: "Help.", + multiagent: { type: "coordinator", agents: ["reviewer", "writer"] }, + }, + { + skill_ids: [], + multiagent: { + type: "coordinator", + members: [ + { logical_name: "reviewer", resource_type: "agent", remote_id: "agent_reviewer_1" }, + { logical_name: "writer", resource_type: "agent", remote_id: "agent_writer_1" }, + ], + }, + }, + ) as Record; + expect(body.multiagent).toEqual({ + type: "coordinator", + agents: [ + { type: "agent", id: "agent_reviewer_1" }, + { type: "agent", id: "agent_writer_1" }, + ], + }); + }); +}); diff --git a/packages/sdk/tests/unit/bailian-examples.test.ts b/packages/sdk/tests/unit/bailian-examples.test.ts index 4ea3992..f447b7e 100644 --- a/packages/sdk/tests/unit/bailian-examples.test.ts +++ b/packages/sdk/tests/unit/bailian-examples.test.ts @@ -9,6 +9,32 @@ import "../../src/internal/providers/bailian/index.ts"; const EXAMPLES = resolve(import.meta.dir, "../../../../examples"); const emptyState: StateFile = { resources: [] }; +test("bailian multiagent example plans members before the coordinator", async () => { + const { config, errors } = await loadConfig(resolve(EXAMPLES, "bailian/multiagent/agents.yaml")); + expect(errors).toEqual([]); + + const lead = config.agents?.lead; + expect(lead?.multiagent).toEqual({ + type: "coordinator", + agents: ["researcher", "writer", { agent_id: "agent_external_reviewer" }], + }); + // Children run in parallel on a shared file system — the coordinator's + // instructions must declare file ownership. + expect(lead?.instructions).toContain("parallel"); + expect(lead?.instructions).toContain("file system"); + expect(config.agents?.researcher?.instructions).toContain("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/work/research/"); + expect(config.agents?.writer?.instructions).toContain("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/work/report/"); + + const plan = await buildPlan(config, emptyState); + expect(plan.diagnostics).toEqual([]); + expect(plan.actions.map((a) => `${a.action}:${a.address.type}:${a.address.name}`)).toEqual([ + "create:environment:dev", + "create:agent:researcher", + "create:agent:writer", + "create:agent:lead", + ]); +}); + test("bailian WebSearch example declares a runnable deployment", async () => { const { config, errors } = await loadConfig(resolve(EXAMPLES, "bailian/with-mcp/agents.yaml")); expect(errors).toEqual([]); diff --git a/packages/sdk/tests/unit/bailian.test.ts b/packages/sdk/tests/unit/bailian.test.ts index 2cbfe6e..17f361b 100644 --- a/packages/sdk/tests/unit/bailian.test.ts +++ b/packages/sdk/tests/unit/bailian.test.ts @@ -366,3 +366,63 @@ describe("Bailian mapEnvironment", () => { expect(() => mapEnvironment("locked", decl, "proj")).toThrow(/only supports networking.type 'unrestricted'/); }); }); + +// --- Multiagent roster (Qoder/Bailian Multi-Agent plan) --- + +describe("Bailian mapAgent multiagent", () => { + const decl: AgentDecl = { + model: "qwen3.7-max", + instructions: "You are a helpful assistant.", + multiagent: { type: "coordinator", agents: ["reviewer", "writer"] }, + }; + + const rosterRefs: ResolvedAgentRefs = { + skill_ids: [], + multiagent: { + type: "coordinator", + members: [ + { logical_name: "reviewer", resource_type: "agent", remote_id: "agent_reviewer_1" }, + { logical_name: "writer", resource_type: "agent", remote_id: "agent_writer_1" }, + ], + }, + }; + + const expectedWire = { + type: "coordinator", + agents: [ + { type: "agent", id: "agent_reviewer_1" }, + { type: "agent", id: "agent_writer_1" }, + ], + }; + + test("maps the roster without member version fields", () => { + const body = mapAgent("lead", decl, rosterRefs) as Record; + expect(body.multiagent).toEqual(expectedWire); + }); + + test("create omits the multiagent field when the declaration has no roster", () => { + const soloDecl: AgentDecl = { model: "qwen3.7-max", instructions: "Help." }; + const body = mapAgent("solo", soloDecl, { skill_ids: [] }, undefined, undefined, undefined, "create") as Record< + string, + unknown + >; + expect("multiagent" in body).toBe(false); + }); + + test("update clears a previously set roster with an empty coordinator", () => { + const soloDecl: AgentDecl = { model: "qwen3.7-max", instructions: "Help." }; + const body = mapAgent("solo", soloDecl, { skill_ids: [] }, undefined, undefined, undefined, "update") as Record< + string, + unknown + >; + expect(body.multiagent).toEqual({ type: "coordinator", agents: [] }); + }); + + test("update keeps the roster when the declaration still declares members", () => { + const body = mapAgent("lead", decl, rosterRefs, undefined, undefined, undefined, "update") as Record< + string, + unknown + >; + expect(body.multiagent).toEqual(expectedWire); + }); +}); diff --git a/packages/sdk/tests/unit/destroy-runtime.test.ts b/packages/sdk/tests/unit/destroy-runtime.test.ts index 7f32273..af06c3c 100644 --- a/packages/sdk/tests/unit/destroy-runtime.test.ts +++ b/packages/sdk/tests/unit/destroy-runtime.test.ts @@ -501,3 +501,30 @@ describe("destroy runtime", () => { expect(runtime.state.listResources()).toHaveLength(1); }); }); + +describe("destroy runtime multiagent ordering", () => { + test("destroys a multiagent coordinator before its members", async () => { + const calls: string[] = []; + const runtime = await ctx( + [ + resource("agent", "reviewer", "agent_reviewer_1"), + resource("agent", "writer", "agent_writer_1"), + resource("agent", "lead", "agent_lead_1"), + ], + adapter(calls), + ); + runtime.config.agents = { + reviewer: { model: { qoder: "auto" }, instructions: "Help." }, + writer: { model: { qoder: "auto" }, instructions: "Help." }, + lead: { + model: { qoder: "auto" }, + instructions: "Help.", + multiagent: { type: "coordinator", agents: ["reviewer", "writer"] }, + }, + }; + + await destroyPlannedProjectResources(planDestroyProjectContext(runtime)); + + expect(calls).toEqual(["agent:agent_lead_1", "agent:agent_reviewer_1", "agent:agent_writer_1"]); + }); +}); diff --git a/packages/sdk/tests/unit/map-deployment.test.ts b/packages/sdk/tests/unit/map-deployment.test.ts index fa37b18..4c35396 100644 --- a/packages/sdk/tests/unit/map-deployment.test.ts +++ b/packages/sdk/tests/unit/map-deployment.test.ts @@ -3,7 +3,7 @@ import { mapDeployment as mapBailianDeployment, mapDeploymentUpdate as mapBailianDeploymentUpdate, } from "../../src/internal/providers/bailian/mapper.ts"; -import { mapDeployment, mapDeploymentUpdate } from "../../src/internal/providers/claude/mapper.ts"; +import { mapAgent, mapDeployment, mapDeploymentUpdate } from "../../src/internal/providers/claude/mapper.ts"; import type { ResolvedDeploymentRefs } from "../../src/internal/providers/interface.ts"; import { mapDeploymentToSession, @@ -506,3 +506,30 @@ describe("Bailian mapDeployment", () => { }); }); }); + +describe("Claude mapAgent multiagent regression", () => { + test("maps a resolved roster to a bare id string array", () => { + const body = mapAgent( + "lead", + { + model: "claude-sonnet-4-20250514", + instructions: "Help.", + multiagent: { type: "coordinator", agents: ["reviewer", "writer"] }, + }, + { + skill_ids: [], + multiagent: { + type: "coordinator", + members: [ + { logical_name: "reviewer", resource_type: "agent", remote_id: "agent_reviewer_1" }, + { logical_name: "writer", resource_type: "agent", remote_id: "agent_writer_1" }, + ], + }, + }, + ) as Record; + expect(body.multiagent).toEqual({ + type: "coordinator", + agents: ["agent_reviewer_1", "agent_writer_1"], + }); + }); +}); diff --git a/packages/sdk/tests/unit/multiagent-comparable.test.ts b/packages/sdk/tests/unit/multiagent-comparable.test.ts new file mode 100644 index 0000000..f2de102 --- /dev/null +++ b/packages/sdk/tests/unit/multiagent-comparable.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, test } from "bun:test"; +import { canonicalizeDesiredRoster, canonicalizeRemoteRoster } from "../../src/internal/multiagent/comparable.ts"; +import type { ResolvedMultiagentRoster } from "../../src/internal/multiagent/model.ts"; +import { BailianAdapter } from "../../src/internal/providers/bailian/adapter.ts"; +import type { ResolvedAgentRefs, ResolvedTemplateRefs } from "../../src/internal/providers/interface.ts"; +import { QoderAdapter } from "../../src/internal/providers/qoder/adapter.ts"; + +function roster(members: ResolvedMultiagentRoster["members"]): ResolvedMultiagentRoster { + return { type: "coordinator", members }; +} + +describe("canonicalizeDesiredRoster", () => { + test("emits sorted unique member ids", () => { + const canonical = canonicalizeDesiredRoster( + roster([ + { logical_name: "writer", resource_type: "agent", remote_id: "agent_writer_1" }, + { logical_name: "reviewer", resource_type: "agent", remote_id: "agent_reviewer_1" }, + { logical_name: "duplicate", resource_type: "agent", remote_id: "agent_reviewer_1" }, + ]), + ); + expect(canonical).toEqual({ type: "coordinator", member_ids: ["agent_reviewer_1", "agent_writer_1"] }); + }); + + test("returns undefined for a missing roster", () => { + expect(canonicalizeDesiredRoster(undefined)).toBeUndefined(); + }); +}); + +describe("canonicalizeRemoteRoster", () => { + test("managed reads id and ignores remote member versions", () => { + const result = canonicalizeRemoteRoster( + { + type: "coordinator", + agents: [ + { type: "agent", id: "agent_writer_1", version: 7 }, + { type: "agent", id: "agent_reviewer_1", version: 5 }, + { type: "agent", id: "agent_reviewer_1", version: 6 }, + ], + }, + "managed", + ); + expect(result).toEqual({ + supported: true, + roster: { type: "coordinator", member_ids: ["agent_reviewer_1", "agent_writer_1"] }, + }); + }); + + test("forward reads template_id and ignores remote member name and version", () => { + const result = canonicalizeRemoteRoster( + { + type: "coordinator", + agents: [ + { type: "agent", template_id: "tmpl_writer_1", name: "Writer Display", version: 2 }, + { type: "agent", template_id: "tmpl_reviewer_1", name: "Reviewer Display", version: 9 }, + ], + }, + "forward", + ); + expect(result).toEqual({ + supported: true, + roster: { type: "coordinator", member_ids: ["tmpl_reviewer_1", "tmpl_writer_1"] }, + }); + }); + + test("treats null, missing and empty rosters as no roster", () => { + for (const raw of [null, undefined, { type: "coordinator", agents: [] }]) { + expect(canonicalizeRemoteRoster(raw, "managed")).toEqual({ supported: true }); + expect(canonicalizeRemoteRoster(raw, "forward")).toEqual({ supported: true }); + } + }); + + test("rejects self, advisor and unknown member types as unsupported", () => { + for (const member of [{ type: "self" }, { type: "advisor", id: "adv_1" }, { type: "relayer", id: "r_1" }]) { + const result = canonicalizeRemoteRoster({ type: "coordinator", agents: [member] }, "managed"); + expect(result.supported).toBe(false); + } + }); +}); + +describe("Qoder comparable round-trips", () => { + const adapter = new QoderAdapter("pt-test", undefined, "tmp") as any; + + test("managed agent desired equals remote despite platform-assigned member versions", () => { + const decl = { + model: "auto", + instructions: "You are the lead.", + multiagent: { type: "coordinator" as const, agents: ["reviewer", "writer"] }, + }; + const refs: ResolvedAgentRefs = { + skill_ids: [], + multiagent: roster([ + { logical_name: "reviewer", resource_type: "agent", remote_id: "agent_reviewer_1" }, + { logical_name: "writer", resource_type: "agent", remote_id: "agent_writer_1" }, + ]), + }; + const remoteRaw = { + name: "lead", + model: "auto", + system: "You are the lead.", + tools: [{ type: "agent_toolset_20260401" }], + multiagent: { + type: "coordinator", + agents: [ + { type: "agent", id: "agent_reviewer_1", version: 5 }, + { type: "agent", id: "agent_writer_1", version: 7 }, + ], + }, + metadata: { "agents.project": "tmp", "agents.resource": "lead" }, + }; + + const desired = adapter.normalizeDesiredResource("agent", "lead", decl, refs); + const remote = adapter.normalizeRemote("agent", remoteRaw); + + expect(remote).toEqual(desired); + expect(remote).toMatchObject({ + multiagent: { type: "coordinator", member_ids: ["agent_reviewer_1", "agent_writer_1"] }, + }); + }); + + test("forward template desired equals remote despite remote member name and version", () => { + const decl = { + model: "auto", + instructions: "You are the lead.", + environment: "dev", + multiagent: { type: "coordinator" as const, agents: ["reviewer"] }, + }; + const refs: ResolvedTemplateRefs = { + skill_ids: [], + environment_id: "env_dev", + vault_ids: [], + multiagent: roster([{ logical_name: "reviewer", resource_type: "template", remote_id: "tmpl_reviewer_1" }]), + }; + const remoteRaw = { + name: "lead", + description: "", + model: "auto", + system: "You are the lead.", + environment_id: "env_dev", + vault_ids: [], + files: {}, + skills: [], + multiagent: { + type: "coordinator", + agents: [{ type: "agent", template_id: "tmpl_reviewer_1", name: "Reviewer Display", version: 3 }], + }, + metadata: { "agents.project": "tmp", "agents.resource": "lead" }, + }; + + const desired = adapter.normalizeDesiredResource("template", "lead", decl, refs); + const remote = adapter.normalizeRemote("template", remoteRaw); + + expect(remote).toEqual(desired); + expect(remote).toMatchObject({ + multiagent: { type: "coordinator", member_ids: ["tmpl_reviewer_1"] }, + }); + }); + + test("clearing the roster canonicalizes to no roster on both sides", () => { + const decl = { model: "auto", instructions: "You are the lead." }; + const desired = adapter.normalizeDesiredResource("agent", "lead", decl, { skill_ids: [] }); + const remote = adapter.normalizeRemote("agent", { + name: "lead", + model: "auto", + system: "You are the lead.", + tools: [{ type: "agent_toolset_20260401" }], + multiagent: null, + metadata: { "agents.project": "tmp", "agents.resource": "lead" }, + }); + expect(remote).toEqual(desired); + }); +}); + +describe("Bailian comparable round-trips", () => { + const adapter = new BailianAdapter("sk-test", "ws-test", "https://bailian.test/api/v1/agentstudio") as any; + + test("null and numeric member versions both stay drift-free", () => { + const decl = { + model: "qwen3.7-max", + instructions: "You are the lead.", + multiagent: { type: "coordinator" as const, agents: ["reviewer", "writer"] }, + }; + const refs: ResolvedAgentRefs = { + skill_ids: [], + multiagent: roster([ + { logical_name: "reviewer", resource_type: "agent", remote_id: "agent_reviewer_1" }, + { logical_name: "writer", resource_type: "agent", remote_id: "agent_writer_1" }, + ]), + }; + const desired = adapter.normalizeDesiredResource("agent", "lead", decl, refs); + + for (const version of [null, 2]) { + const remote = adapter.normalizeRemote("agent", { + name: "lead", + model: "qwen3.7-max", + system: "You are the lead.", + tools: [], + multiagent: { + type: "coordinator", + agents: [ + { type: "agent", id: "agent_reviewer_1", version }, + { type: "agent", id: "agent_writer_1", version }, + ], + }, + metadata: {}, + }); + expect(remote).toEqual(desired); + expect(remote).toMatchObject({ + multiagent: { type: "coordinator", member_ids: ["agent_reviewer_1", "agent_writer_1"] }, + }); + } + }); + + test("clearing the roster canonicalizes to no roster on both sides", () => { + const decl = { model: "qwen3.7-max", instructions: "You are the lead." }; + const desired = adapter.normalizeDesiredResource("agent", "lead", decl, { skill_ids: [] }); + const remote = adapter.normalizeRemote("agent", { + name: "lead", + model: "qwen3.7-max", + system: "You are the lead.", + tools: [], + multiagent: { type: "coordinator", agents: [] }, + metadata: {}, + }); + expect(remote).toEqual(desired); + }); +}); diff --git a/packages/sdk/tests/unit/multiagent-resolver.test.ts b/packages/sdk/tests/unit/multiagent-resolver.test.ts new file mode 100644 index 0000000..2c592bb --- /dev/null +++ b/packages/sdk/tests/unit/multiagent-resolver.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveAgentRefs, resolveTemplateRefs } from "../../src/internal/executor/resolver.ts"; +import { StateManager } from "../../src/internal/state/state-manager.ts"; +import type { ProjectConfig } from "../../src/internal/types/config.ts"; + +function tmpPath(): string { + return join(tmpdir(), `multiagent-resolver-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); +} + +function setAgent(state: StateManager, name: string, remoteId: string, type: "agent" | "template" = "agent") { + state.setResource({ + address: { type, name, provider: "qoder" }, + remote_id: remoteId, + content_hash: "h", + desired_hash: "h", + }); +} + +const managedConfig: ProjectConfig = { + version: "1", + providers: { qoder: {} }, + defaults: { provider: "qoder" }, + agents: { + lead: { + model: "auto", + instructions: "Coordinate.", + multiagent: { type: "coordinator", agents: ["reviewer", "writer"] }, + }, + reviewer: { model: "auto", instructions: "Review." }, + writer: { model: "auto", instructions: "Write." }, + }, +}; + +const forwardConfig: ProjectConfig = { + version: "1", + providers: { qoder: {} }, + defaults: { provider: "qoder" }, + environments: { dev: { environment_id: "env_dev" } }, + agents: { + lead: { + model: "auto", + instructions: "Coordinate.", + environment: "dev", + delivery: { qoder: { type: "forward" } }, + multiagent: { type: "coordinator", agents: ["reviewer", "writer"] }, + }, + reviewer: { + model: "auto", + instructions: "Review.", + environment: "dev", + delivery: { qoder: { type: "forward" } }, + }, + writer: { + model: "auto", + instructions: "Write.", + environment: "dev", + delivery: { qoder: { type: "forward" } }, + }, + }, +}; + +describe("materialization-aware reference resolution", () => { + test("resolves Managed coordinator members as agent addresses", () => { + const state = StateManager.initialize(tmpPath()); + setAgent(state, "reviewer", "agent_reviewer_1"); + setAgent(state, "writer", "agent_writer_1"); + + const refs = resolveAgentRefs("lead", managedConfig, "qoder", state); + + expect(refs.multiagent).toEqual({ + type: "coordinator", + members: [ + { logical_name: "reviewer", resource_type: "agent", remote_id: "agent_reviewer_1" }, + { logical_name: "writer", resource_type: "agent", remote_id: "agent_writer_1" }, + ], + }); + }); + + test("resolves a mixed Managed roster without requiring external members in state", () => { + const state = StateManager.initialize(tmpPath()); + setAgent(state, "reviewer", "agent_reviewer_1"); + const config: ProjectConfig = { + ...managedConfig, + agents: { + ...managedConfig.agents, + lead: { + ...managedConfig.agents!.lead!, + multiagent: { type: "coordinator", agents: ["reviewer", { agent_id: "agent_external_1" }] }, + }, + }, + }; + + const refs = resolveAgentRefs("lead", config, "qoder", state); + + expect(refs.multiagent).toEqual({ + type: "coordinator", + members: [ + { logical_name: "reviewer", resource_type: "agent", remote_id: "agent_reviewer_1" }, + { resource_type: "agent", remote_id: "agent_external_1" }, + ], + }); + }); + + test("resolves Forward coordinator members as template addresses", () => { + const state = StateManager.initialize(tmpPath()); + setAgent(state, "reviewer", "tmpl_reviewer_1", "template"); + setAgent(state, "writer", "tmpl_writer_1", "template"); + + const refs = resolveTemplateRefs("lead", forwardConfig, "qoder", state); + + expect(refs.multiagent).toEqual({ + type: "coordinator", + members: [ + { logical_name: "reviewer", resource_type: "template", remote_id: "tmpl_reviewer_1" }, + { logical_name: "writer", resource_type: "template", remote_id: "tmpl_writer_1" }, + ], + }); + }); + + test("throws instead of silently skipping a member missing from state", () => { + const state = StateManager.initialize(tmpPath()); + setAgent(state, "reviewer", "agent_reviewer_1"); + + expect(() => resolveAgentRefs("lead", managedConfig, "qoder", state)).toThrow( + /qoder\.agent\.writer not found in state\. Run `agents apply` first\./, + ); + }); + + test("throws for a Forward member missing from state", () => { + const state = StateManager.initialize(tmpPath()); + setAgent(state, "reviewer", "tmpl_reviewer_1", "template"); + + expect(() => resolveTemplateRefs("lead", forwardConfig, "qoder", state)).toThrow( + /qoder\.template\.writer not found in state\. Run `agents apply` first\./, + ); + }); + + test("rejects an external Managed Agent in a Forward roster", () => { + const state = StateManager.initialize(tmpPath()); + const config: ProjectConfig = { + ...forwardConfig, + agents: { + ...forwardConfig.agents, + lead: { + ...forwardConfig.agents!.lead!, + multiagent: { type: "coordinator", agents: [{ agent_id: "agent_external_1" }] }, + }, + }, + }; + + expect(() => resolveTemplateRefs("lead", config, "qoder", state)).toThrow( + /qoder\.template\.multiagent\.external_member/, + ); + }); + + test("leaves the roster unset for an agent without multiagent", () => { + const state = StateManager.initialize(tmpPath()); + const refs = resolveAgentRefs("reviewer", managedConfig, "qoder", state); + expect(refs.multiagent).toBeUndefined(); + }); + + test("keeps Forward template refs free of agent-address multiagent members", () => { + const state = StateManager.initialize(tmpPath()); + setAgent(state, "reviewer", "tmpl_reviewer_1", "template"); + setAgent(state, "writer", "tmpl_writer_1", "template"); + + const refs = resolveTemplateRefs("lead", forwardConfig, "qoder", state); + + expect(refs.multiagent?.members.every((member) => member.resource_type === "template")).toBe(true); + expect(refs.environment_id).toBe("env_dev"); + }); +}); diff --git a/packages/sdk/tests/unit/multiagent-reverse-index.test.ts b/packages/sdk/tests/unit/multiagent-reverse-index.test.ts new file mode 100644 index 0000000..b131316 --- /dev/null +++ b/packages/sdk/tests/unit/multiagent-reverse-index.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "bun:test"; +import { buildReverseIndex, memberDeclResolver } from "../../src/internal/multiagent/reverse-index.ts"; +import { agentToDecl } from "../../src/internal/providers/qoder/mapper.ts"; + +function remoteAgent(id: string, overrides: Record = {}): Record { + return { + id, + type: "agent", + name: `display-${id}`, + model: "auto", + system: "Help.", + archived_at: null, + metadata: { "agents.project": "tmp", "agents.resource": id }, + ...overrides, + }; +} + +function coordinatorRaw(memberIds: string[]): Record { + return remoteAgent("lead", { + multiagent: { + type: "coordinator", + agents: memberIds.map((id) => ({ type: "agent", id })), + }, + }); +} + +describe("reverse index construction", () => { + test("derives logical names only from managed metadata with a matching project", () => { + const index = buildReverseIndex( + [ + remoteAgent("agent_reviewer_1", { metadata: { "agents.project": "tmp", "agents.resource": "reviewer" } }), + remoteAgent("agent_foreign_1", { metadata: { "agents.project": "other", "agents.resource": "spy" } }), + remoteAgent("agent_unmanaged_1", { metadata: {} }), + remoteAgent("agent_archived_1", { + metadata: { "agents.project": "tmp", "agents.resource": "ghost" }, + archived_at: "2026-07-19T00:00:00Z", + }), + ], + "tmp", + ); + + expect(index.get("agent_reviewer_1")).toMatchObject({ logical_name: "reviewer", archived: false, owned: true }); + expect(index.get("agent_foreign_1")).toMatchObject({ owned: false }); + expect(index.get("agent_unmanaged_1")).toMatchObject({ owned: false }); + expect(index.get("agent_archived_1")).toMatchObject({ logical_name: "ghost", archived: true, owned: true }); + }); + + test("never falls back to display names or ids for logical names", () => { + const index = buildReverseIndex([remoteAgent("agent_1", { metadata: {} })], "tmp"); + const entry = index.get("agent_1")!; + expect(entry.logical_name).toBeUndefined(); + }); +}); + +describe("reverse mapping strategies", () => { + test("maps a full roster to logical names", () => { + const agents = [ + coordinatorRaw(["agent_reviewer_1", "agent_writer_1"]), + remoteAgent("agent_reviewer_1", { metadata: { "agents.project": "tmp", "agents.resource": "reviewer" } }), + remoteAgent("agent_writer_1", { metadata: { "agents.project": "tmp", "agents.resource": "writer" } }), + ]; + const decl = agentToDecl(agents[0]!, memberDeclResolver(buildReverseIndex(agents, "tmp"))); + expect(decl.multiagent).toEqual({ type: "coordinator", agents: ["reviewer", "writer"] }); + }); + + test("fails closed when a member is missing from the full agent list", () => { + const agents = [coordinatorRaw(["agent_gone_1"])]; + expect(() => agentToDecl(agents[0]!, memberDeclResolver(buildReverseIndex(agents, "tmp")))).toThrow( + /sync\.multiagent\.member\.unresolved/, + ); + }); + + test("fails closed when a member is archived", () => { + const agents = [ + coordinatorRaw(["agent_archived_1"]), + remoteAgent("agent_archived_1", { + metadata: { "agents.project": "tmp", "agents.resource": "ghost" }, + archived_at: "2026-07-19T00:00:00Z", + }), + ]; + expect(() => agentToDecl(agents[0]!, memberDeclResolver(buildReverseIndex(agents, "tmp")))).toThrow( + /sync\.multiagent\.member\.archived/, + ); + }); + + test("preserves a member owned by another project as an external Agent reference", () => { + const agents = [ + coordinatorRaw(["agent_foreign_1"]), + remoteAgent("agent_foreign_1", { metadata: { "agents.project": "other", "agents.resource": "spy" } }), + ]; + const decl = agentToDecl(agents[0]!, memberDeclResolver(buildReverseIndex(agents, "tmp"))); + expect(decl.multiagent).toEqual({ type: "coordinator", agents: [{ agent_id: "agent_foreign_1" }] }); + }); + + test("preserves an unmanaged member as an external Agent reference", () => { + const agents = [coordinatorRaw(["agent_external_1"]), remoteAgent("agent_external_1", { metadata: {} })]; + const decl = agentToDecl(agents[0]!, memberDeclResolver(buildReverseIndex(agents, "tmp"))); + expect(decl.multiagent).toEqual({ type: "coordinator", agents: [{ agent_id: "agent_external_1" }] }); + }); + + test("fails closed when multiple remote resources claim the same logical name", () => { + const agents = [ + coordinatorRaw(["agent_reviewer_1"]), + remoteAgent("agent_reviewer_1", { metadata: { "agents.project": "tmp", "agents.resource": "reviewer" } }), + remoteAgent("agent_reviewer_2", { + name: "display-agent_reviewer_2", + metadata: { "agents.project": "tmp", "agents.resource": "reviewer" }, + }), + ]; + expect(() => agentToDecl(agents[0]!, memberDeclResolver(buildReverseIndex(agents, "tmp")))).toThrow( + /sync\.multiagent\.member\.ambiguous/, + ); + }); + + test("leaves agents without a roster untouched by the resolver", () => { + const agents = [ + remoteAgent("agent_reviewer_1", { metadata: { "agents.project": "tmp", "agents.resource": "reviewer" } }), + ]; + const decl = agentToDecl(agents[0]!, memberDeclResolver(buildReverseIndex(agents, "tmp"))); + expect(decl.multiagent).toBeUndefined(); + }); +}); diff --git a/packages/sdk/tests/unit/multiagent-topology.test.ts b/packages/sdk/tests/unit/multiagent-topology.test.ts new file mode 100644 index 0000000..49b3d40 --- /dev/null +++ b/packages/sdk/tests/unit/multiagent-topology.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, test } from "bun:test"; +import { collectConfigReferences, validateProjectConfig } from "../../src/internal/core/validate-config.ts"; +import { projectConfigSchema } from "../../src/internal/parser/schema.ts"; +import type { AgentDecl, ProjectConfig } from "../../src/internal/types/config.ts"; + +function agent(decl: Partial = {}): AgentDecl { + return { model: "auto", instructions: "Help.", ...decl }; +} + +function multiagentConfig( + agents: Record, + providers: Record = { qoder: {} }, +): ProjectConfig { + return { + version: "1", + providers, + defaults: { provider: Object.keys(providers)[0]! }, + agents, + }; +} + +function roster(...members: string[]) { + return { type: "coordinator" as const, agents: members }; +} + +function memberNames(n: number): string[] { + return Array.from({ length: n }, (_, i) => `worker_${i + 1}`); +} + +function codes(diagnostics: Array<{ code: string }>): string[] { + return diagnostics.map((d) => d.code); +} + +describe("multiagent reference topology", () => { + test("treats an external Agent id as a non-owned leaf", () => { + const diagnostics = collectConfigReferences( + multiagentConfig({ + lead: agent({ multiagent: { type: "coordinator", agents: [{ agent_id: "agent_external_1" }] } }), + }), + ); + expect(codes(diagnostics)).not.toContain("config.agent.multiagent.unknown"); + expect(codes(diagnostics)).not.toContain("config.agent.multiagent.self"); + expect(codes(diagnostics)).not.toContain("config.agent.multiagent.nested"); + }); + test("flags a member that is not declared in the project", () => { + const diagnostics = collectConfigReferences(multiagentConfig({ lead: agent({ multiagent: roster("reviewer") }) })); + expect(codes(diagnostics)).toContain("config.agent.multiagent.unknown"); + expect(diagnostics.find((d) => d.code === "config.agent.multiagent.unknown")?.message).toContain("reviewer"); + }); + + test("flags a coordinator referencing itself", () => { + const diagnostics = collectConfigReferences(multiagentConfig({ lead: agent({ multiagent: roster("lead") }) })); + expect(codes(diagnostics)).toContain("config.agent.multiagent.self"); + }); + + test("rejects a coordinator referencing another coordinator", () => { + const diagnostics = collectConfigReferences( + multiagentConfig({ + lead: agent({ multiagent: roster("reviewer", "worker") }), + reviewer: agent({ multiagent: roster("worker") }), + worker: agent(), + }), + ); + expect(codes(diagnostics)).toContain("config.agent.multiagent.nested"); + expect(codes(diagnostics)).not.toContain("config.agent.multiagent.cycle"); + }); + + test("rejects a two-node cycle with the full path in the message", () => { + const diagnostics = collectConfigReferences( + multiagentConfig({ + lead: agent({ multiagent: roster("reviewer") }), + reviewer: agent({ multiagent: roster("lead") }), + }), + ); + const cycle = diagnostics.find((d) => d.code === "config.agent.multiagent.cycle"); + expect(cycle).toBeDefined(); + expect(cycle?.message).toContain("lead -> reviewer -> lead"); + }); + + test("rejects a three-node cycle with the full path in the message", () => { + const diagnostics = collectConfigReferences( + multiagentConfig({ + alpha: agent({ multiagent: roster("beta") }), + beta: agent({ multiagent: roster("gamma") }), + gamma: agent({ multiagent: roster("alpha") }), + }), + ); + const cycle = diagnostics.find((d) => d.code === "config.agent.multiagent.cycle"); + expect(cycle).toBeDefined(); + expect(cycle?.message).toContain("alpha -> beta -> gamma -> alpha"); + }); + + test("emits stable diagnostics independent of agent declaration order", () => { + const forward = multiagentConfig({ + lead: agent({ multiagent: roster("reviewer") }), + reviewer: agent({ multiagent: roster("lead") }), + }); + const reversed = multiagentConfig({ + reviewer: agent({ multiagent: roster("lead") }), + lead: agent({ multiagent: roster("reviewer") }), + }); + const forwardCodes = collectConfigReferences(forward) + .map((d) => d.code) + .sort(); + const reversedCodes = collectConfigReferences(reversed) + .map((d) => d.code) + .sort(); + expect(forwardCodes).toEqual(reversedCodes); + expect(forwardCodes).toContain("config.agent.multiagent.cycle"); + }); +}); + +describe("multiagent roster shape", () => { + test("rejects an empty roster at the common schema level", () => { + const parsed = projectConfigSchema.safeParse({ + version: "1", + providers: { qoder: {} }, + agents: { lead: { model: "auto", instructions: "Help.", multiagent: roster() } }, + }); + expect(parsed.success).toBe(false); + }); + + test("rejects duplicate members at the common schema level", () => { + const parsed = projectConfigSchema.safeParse({ + version: "1", + providers: { qoder: {} }, + agents: { + lead: { model: "auto", instructions: "Help.", multiagent: roster("a", "b", "a") }, + a: { model: "auto", instructions: "Help." }, + b: { model: "auto", instructions: "Help." }, + }, + }); + expect(parsed.success).toBe(false); + }); + + test("accepts external Agent references and rejects duplicate external ids", () => { + const base = { + version: "1", + providers: { qoder: {} }, + agents: { + lead: { + model: "auto", + instructions: "Help.", + multiagent: { type: "coordinator", agents: [{ agent_id: "agent_external_1" }] }, + }, + }, + }; + expect(projectConfigSchema.safeParse(base).success).toBe(true); + expect( + projectConfigSchema.safeParse({ + ...base, + agents: { + lead: { + ...base.agents.lead, + multiagent: { + type: "coordinator", + agents: [{ agent_id: "agent_external_1" }, { agent_id: "agent_external_1" }], + }, + }, + }, + }).success, + ).toBe(false); + }); + + test("accepts a roster with 20 members at the common schema level", () => { + const members = memberNames(20); + const agents: Record = { lead: agent({ multiagent: roster(...members) }) }; + for (const member of members) agents[member] = agent(); + const parsed = projectConfigSchema.safeParse({ + version: "1", + providers: { qoder: {} }, + agents, + }); + expect(parsed.success).toBe(true); + }); + + test("enforces the 20-member cap in provider-aware validation for qoder", () => { + const members = memberNames(21); + const agents: Record = { lead: agent({ multiagent: roster(...members) }) }; + for (const member of members) agents[member] = agent(); + const diagnostics = validateProjectConfig(multiagentConfig(agents)); + expect(codes(diagnostics)).toContain("qoder.agent.multiagent.member_limit"); + }); + + test("enforces the 20-member cap in provider-aware validation for bailian", () => { + const members = memberNames(21); + const agents: Record = { lead: agent({ multiagent: roster(...members) }) }; + for (const member of members) agents[member] = agent(); + const diagnostics = validateProjectConfig(multiagentConfig(agents, { bailian: {} })); + expect(codes(diagnostics)).toContain("bailian.agent.multiagent.member_limit"); + }); + + test("does not impose the 20-member cap on providers without such a limit", () => { + const members = memberNames(21); + const agents: Record = { lead: agent({ multiagent: roster(...members) }) }; + for (const member of members) agents[member] = agent(); + const diagnostics = validateProjectConfig(multiagentConfig(agents, { claude: {} })); + expect(codes(diagnostics)).not.toContain("qoder.agent.multiagent.member_limit"); + expect(codes(diagnostics)).not.toContain("bailian.agent.multiagent.member_limit"); + }); +}); + +describe("multiagent materialization matching", () => { + function forwardConfig(members: string[]): ProjectConfig { + const agents: Record = { + lead: agent({ + environment: "dev", + delivery: { qoder: { type: "forward" } }, + multiagent: roster(...members), + }), + }; + for (const member of members) { + agents[member] = agent({ + environment: "dev", + delivery: { qoder: { type: "forward" } }, + }); + } + return { + version: "1", + providers: { qoder: {} }, + defaults: { provider: "qoder" }, + environments: { dev: { config: { type: "cloud" } } }, + agents, + }; + } + + test("accepts a Forward coordinator referencing Forward members", () => { + const diagnostics = validateProjectConfig(forwardConfig(["reviewer", "writer"])); + expect(codes(diagnostics)).not.toContain("qoder.template.multiagent.unsupported"); + expect(codes(diagnostics)).not.toContain("qoder.template.multiagent.member_materialization"); + }); + + test("rejects an external Managed Agent reference on Qoder Forward", () => { + const config = forwardConfig([]); + config.agents!.lead!.multiagent = { type: "coordinator", agents: [{ agent_id: "agent_external_1" }] }; + const diagnostics = validateProjectConfig(config); + expect(codes(diagnostics)).toContain("qoder.template.multiagent.external_member"); + }); + + test("accepts an external Managed Agent reference on Qoder Managed", () => { + const diagnostics = validateProjectConfig( + multiagentConfig({ + lead: agent({ multiagent: { type: "coordinator", agents: [{ agent_id: "agent_external_1" }] } }), + }), + ); + expect(codes(diagnostics)).not.toContain("qoder.template.multiagent.external_member"); + }); + + test("rejects a Forward coordinator referencing a Managed member", () => { + const config = forwardConfig(["reviewer"]); + config.agents!.reviewer = agent({ environment: "dev" }); + const diagnostics = validateProjectConfig(config); + expect(codes(diagnostics)).toContain("qoder.template.multiagent.member_materialization"); + expect(codes(diagnostics)).not.toContain("qoder.template.multiagent.unsupported"); + }); + + test("rejects a Managed coordinator referencing a Forward member", () => { + const config: ProjectConfig = { + version: "1", + providers: { qoder: {} }, + defaults: { provider: "qoder" }, + environments: { dev: { config: { type: "cloud" } } }, + agents: { + lead: agent({ multiagent: roster("reviewer") }), + reviewer: agent({ environment: "dev", delivery: { qoder: { type: "forward" } } }), + }, + }; + const diagnostics = validateProjectConfig(config); + expect(codes(diagnostics)).toContain("qoder.template.multiagent.member_materialization"); + }); + + test("keeps the reference-only pipeline free of provider materialization rules", () => { + const config = forwardConfig(["reviewer"]); + config.agents!.reviewer = agent({ environment: "dev" }); + const diagnostics = collectConfigReferences(config); + expect(codes(diagnostics)).not.toContain("qoder.template.multiagent.member_materialization"); + }); +}); diff --git a/packages/sdk/tests/unit/provider-conformance.test.ts b/packages/sdk/tests/unit/provider-conformance.test.ts index 248ab2d..fbe835e 100644 --- a/packages/sdk/tests/unit/provider-conformance.test.ts +++ b/packages/sdk/tests/unit/provider-conformance.test.ts @@ -28,6 +28,8 @@ const RESOURCE_KIND_METHODS: Record { + if (providerDef.capabilities.multiagent.tier !== "native") return; + expect(providerDef.capabilities.agent.tier).toBe("native"); + }); + for (const kind of ALL_RESOURCE_KINDS) { const mapping = RESOURCE_KIND_METHODS[kind]; if (mapping.skip) continue; diff --git a/packages/sdk/tests/unit/provider-shared.test.ts b/packages/sdk/tests/unit/provider-shared.test.ts index 9206da4..c7af298 100644 --- a/packages/sdk/tests/unit/provider-shared.test.ts +++ b/packages/sdk/tests/unit/provider-shared.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { BaseApiClient } from "../../src/internal/providers/base-client.ts"; +import { agentToDecl } from "../../src/internal/providers/qoder/mapper.ts"; import { buildSessionInfo, type ExportMappers, @@ -151,6 +152,13 @@ const idMappers: ExportMappers = { skillToDecl: (r, name) => ({ kind: "skill", id: r.id, name }), }; +// Real qoder mapper so the agent branch test exercises the full shared.ts → +// reverse-index → mapper pipeline, not a stub of it. +const agentMappers: ExportMappers = { + ...idMappers, + agentToDecl, +}; + describe("exportRemoteResources", () => { test("file branch tolerates file_id (qoder) and id (claude/bailian)", async () => { const c = new ExportStub(); @@ -199,4 +207,101 @@ describe("exportRemoteResources", () => { const c = new ExportStub(); expect(await exportRemoteResources(c, "memory_store", idMappers)).toEqual([]); }); + + test("agent branch exports logical names and keeps archived agents out of the listing", async () => { + const c = new ExportStub(); + c.pagedByPath = { + "/agents": [ + { + id: "lead-1", + name: "Lead Agent", + archived_at: null, + metadata: { "agents.project": "proj", "agents.resource": "lead" }, + multiagent: { type: "coordinator", agents: [{ type: "agent", id: "writer-1" }] }, + }, + { + id: "writer-1", + name: "Writer", + archived_at: null, + metadata: { "agents.project": "proj", "agents.resource": "writer" }, + }, + { + id: "ghost-1", + name: "Ghost", + archived_at: "2026-01-01T00:00:00Z", + metadata: { "agents.project": "proj", "agents.resource": "ghost" }, + }, + ], + }; + const out = await exportRemoteResources(c, "agent", agentMappers, "proj"); + expect(out.map((o) => o.name)).toEqual(["lead", "writer"]); + const lead = out[0]!.decl as { multiagent?: { agents: string[] } }; + expect(lead.multiagent?.agents).toEqual(["writer"]); + }); + + test("agent branch fails closed on archived roster members", async () => { + const c = new ExportStub(); + c.pagedByPath = { + "/agents": [ + { + id: "lead-1", + name: "Lead", + archived_at: null, + metadata: { "agents.project": "proj", "agents.resource": "lead" }, + multiagent: { type: "coordinator", agents: [{ type: "agent", id: "arch-1" }] }, + }, + { + id: "arch-1", + name: "Arch", + archived_at: "2026-01-01T00:00:00Z", + metadata: { "agents.project": "proj", "agents.resource": "arch" }, + }, + ], + }; + await expect(exportRemoteResources(c, "agent", agentMappers, "proj")).rejects.toThrow( + /sync\.multiagent\.member\.archived/, + ); + }); + + test("agent branch exports roster members owned by another project as external references", async () => { + const c = new ExportStub(); + c.pagedByPath = { + "/agents": [ + { + id: "lead-1", + name: "Lead", + archived_at: null, + metadata: { "agents.project": "proj", "agents.resource": "lead" }, + multiagent: { type: "coordinator", agents: [{ type: "agent", id: "foreign-1" }] }, + }, + { + id: "foreign-1", + name: "Foreign", + archived_at: null, + metadata: { "agents.project": "other", "agents.resource": "foreign" }, + }, + ], + }; + const out = await exportRemoteResources(c, "agent", agentMappers, "proj"); + const lead = out[0]!.decl as { multiagent?: { agents: Array<{ agent_id: string }> } }; + expect(lead.multiagent?.agents).toEqual([{ agent_id: "foreign-1" }]); + }); + + test("agent branch fails closed on roster members missing from the listing", async () => { + const c = new ExportStub(); + c.pagedByPath = { + "/agents": [ + { + id: "lead-1", + name: "Lead", + archived_at: null, + metadata: { "agents.project": "proj", "agents.resource": "lead" }, + multiagent: { type: "coordinator", agents: [{ type: "agent", id: "missing-1" }] }, + }, + ], + }; + await expect(exportRemoteResources(c, "agent", agentMappers, "proj")).rejects.toThrow( + /sync\.multiagent\.member\.unresolved/, + ); + }); }); diff --git a/packages/sdk/tests/unit/qoder-examples.test.ts b/packages/sdk/tests/unit/qoder-examples.test.ts index 02d2655..ea12405 100644 --- a/packages/sdk/tests/unit/qoder-examples.test.ts +++ b/packages/sdk/tests/unit/qoder-examples.test.ts @@ -86,7 +86,7 @@ test("qoder agent mapper preserves declared tool permission policies", () => { permissions: { Read: "allow", Bash: "ask" }, }, }, - { skill_ids: [], memory_store_ids: [], multiagent_agent_ids: [] }, + { skill_ids: [], memory_store_ids: [] }, ) as Record; expect(body.tools).toEqual([ { @@ -138,3 +138,146 @@ test("qoder sync preserves tool permission policies", () => { permissions: { read: "allow", bash: "ask" }, }); }); + +test("qoder managed agent mapper emits the coordinator roster wire format", () => { + const body = mapAgent( + "lead", + { + model: "auto", + instructions: "Coordinate.", + multiagent: { type: "coordinator", agents: ["reviewer", "writer"] }, + }, + { + skill_ids: [], + multiagent: { + type: "coordinator", + members: [ + { logical_name: "reviewer", resource_type: "agent", remote_id: "agent_reviewer_1" }, + { logical_name: "writer", resource_type: "agent", remote_id: "agent_writer_1" }, + ], + }, + }, + ) as Record; + expect(body.multiagent).toEqual({ + type: "coordinator", + agents: [ + { type: "agent", id: "agent_reviewer_1" }, + { type: "agent", id: "agent_writer_1" }, + ], + }); + const tools = body.tools as Array<{ type: string }>; + expect(tools.filter((tool) => tool.type === "agent_toolset_20260401")).toHaveLength(1); +}); + +test("qoder create omits multiagent when the declaration has no roster", () => { + const body = mapAgent( + "solo", + { model: "auto", instructions: "Solo." }, + { skill_ids: [] }, + undefined, + "tmp", + "create", + ) as Record; + expect("multiagent" in body).toBe(false); +}); + +test("qoder update clears a previously set roster with multiagent null", () => { + const body = mapAgent( + "solo", + { model: "auto", instructions: "Solo." }, + { skill_ids: [] }, + undefined, + "tmp", + "update", + ) as Record; + expect(body.multiagent).toBeNull(); +}); + +test("qoder update keeps the roster field when the declaration still declares members", () => { + const body = mapAgent( + "lead", + { + model: "auto", + instructions: "Coordinate.", + multiagent: { type: "coordinator", agents: ["reviewer"] }, + }, + { + skill_ids: [], + multiagent: { + type: "coordinator", + members: [{ logical_name: "reviewer", resource_type: "agent", remote_id: "agent_reviewer_1" }], + }, + }, + undefined, + "tmp", + "update", + ) as Record; + expect(body.multiagent).toEqual({ + type: "coordinator", + agents: [{ type: "agent", id: "agent_reviewer_1" }], + }); +}); + +test("plan creates multiagent members before their coordinator", async () => { + const config = { + version: "1", + providers: { qoder: {} }, + defaults: { provider: "qoder" }, + agents: { + reviewer: { model: "auto", instructions: "Review." }, + writer: { model: "auto", instructions: "Write." }, + lead: { + model: "auto", + instructions: "Coordinate.", + multiagent: { type: "coordinator" as const, agents: ["reviewer", "writer"] }, + }, + }, + }; + const plan = await buildPlan(config, emptyState); + expect(plan.diagnostics).toEqual([]); + expect(plan.actions.map((a) => `${a.action}:${a.address.type}:${a.address.name}`)).toEqual([ + "create:agent:reviewer", + "create:agent:writer", + "create:agent:lead", + ]); +}); + +test("qoder multiagent example (managed) plans members before the coordinator", async () => { + const { config, errors } = await loadConfig(resolve(EXAMPLES, "qoder/multiagent/agents.yaml")); + expect(errors).toEqual([]); + + const lead = config.agents?.lead; + expect(lead?.multiagent).toEqual({ + type: "coordinator", + agents: ["researcher", "writer", { agent_id: "agent_external_reviewer" }], + }); + expect(lead?.delivery).toBeUndefined(); + + const plan = await buildPlan(config, emptyState); + expect(plan.diagnostics).toEqual([]); + expect(plan.actions.map((a) => `${a.action}:${a.address.type}:${a.address.name}`)).toEqual([ + "create:environment:dev", + "create:agent:researcher", + "create:agent:writer", + "create:agent:lead", + ]); +}); + +test("qoder multiagent-forward example materializes every agent as a template", async () => { + const { config, errors } = await loadConfig(resolve(EXAMPLES, "qoder/multiagent-forward/agents.yaml")); + expect(errors).toEqual([]); + + for (const agent of Object.values(config.agents ?? {})) { + expect(agent.delivery?.qoder).toEqual({ type: "forward" }); + } + expect(config.agents?.lead?.multiagent).toEqual({ type: "coordinator", agents: ["researcher", "writer"] }); + + const plan = await buildPlan(config, emptyState); + expect(plan.diagnostics).toEqual([]); + expect(plan.actions.map((a) => `${a.action}:${a.address.type}:${a.address.name}`)).toEqual([ + "create:environment:dev", + "create:template:researcher", + "create:template:writer", + "create:template:lead", + ]); +}); diff --git a/packages/sdk/tests/unit/qoder-forward-template.test.ts b/packages/sdk/tests/unit/qoder-forward-template.test.ts index 8c79d97..66eefd2 100644 --- a/packages/sdk/tests/unit/qoder-forward-template.test.ts +++ b/packages/sdk/tests/unit/qoder-forward-template.test.ts @@ -1207,3 +1207,60 @@ describe("Qoder Forward default memory store", () => { expect(calls).toEqual([["idn_1", "tmpl_1", { name: "Support memory" }]]); }); }); + +describe("Qoder Forward multiagent roster", () => { + const roster = { + type: "coordinator" as const, + members: [ + { logical_name: "reviewer", resource_type: "template" as const, remote_id: "tmpl_reviewer_1" }, + { logical_name: "writer", resource_type: "template" as const, remote_id: "tmpl_writer_1" }, + ], + }; + + const baseRefs = { + environment_id: "env_byoc", + tunnel_id: "tnl_internal", + vault_ids: ["vault_mcp"], + skill_ids: [], + }; + + const expectedWire = { + type: "coordinator", + agents: [ + { type: "agent", template_id: "tmpl_reviewer_1" }, + { type: "agent", template_id: "tmpl_writer_1" }, + ], + }; + + test("emits template_id member refs without id, name, or version fields", () => { + const decl = forwardConfig().agents!.assistant!; + decl.multiagent = { type: "coordinator", agents: ["reviewer", "writer"] }; + const body = mapForwardTemplate("assistant", decl, { ...baseRefs, multiagent: roster }) as Record; + expect(body.multiagent).toEqual(expectedWire); + }); + + test("create omits the multiagent field when the declaration has no roster", () => { + const decl = forwardConfig().agents!.assistant!; + const body = mapForwardTemplate("assistant", decl, baseRefs, undefined, "create") as Record; + expect("multiagent" in body).toBe(false); + }); + + test("update clears a previously set roster with multiagent null", () => { + const decl = forwardConfig().agents!.assistant!; + const body = mapForwardTemplate("assistant", decl, baseRefs, undefined, "update") as Record; + expect(body.multiagent).toBeNull(); + }); + + test("update keeps the roster when the declaration still declares members", () => { + const decl = forwardConfig().agents!.assistant!; + decl.multiagent = { type: "coordinator", agents: ["reviewer", "writer"] }; + const body = mapForwardTemplate( + "assistant", + decl, + { ...baseRefs, multiagent: roster }, + undefined, + "update", + ) as Record; + expect(body.multiagent).toEqual(expectedWire); + }); +}); diff --git a/packages/sdk/tests/unit/slim-state.test.ts b/packages/sdk/tests/unit/slim-state.test.ts index b7263f5..17a90ea 100644 --- a/packages/sdk/tests/unit/slim-state.test.ts +++ b/packages/sdk/tests/unit/slim-state.test.ts @@ -16,7 +16,6 @@ function tmpPath(): string { const emptyRefs: ResolvedAgentRefs = { skill_ids: [], - multiagent_agent_ids: [], }; describe("StateManager backward compat", () => { From e336128f2531002a7708378be8cb38e1fbbcf674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=B9=E5=9D=A4?= Date: Wed, 23 Sep 2026 22:14:05 +0800 Subject: [PATCH 2/3] feat(bailian): add opt-in web and artifact builtin tools - Extend BAILIAN_BUILTINS set with "web_search", "web_fetch", and "mark_artifacts" - Update createAgent to preserve and enable these builtin tools when opted in - Add e2e test to verify opt-in tools are correctly configured in agent creation request --- .../src/internal/providers/bailian/mapper.ts | 12 ++++++++- .../sdk/tests/e2e/bailian-adapter.test.ts | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/internal/providers/bailian/mapper.ts b/packages/sdk/src/internal/providers/bailian/mapper.ts index 42fc3de..acc10e7 100644 --- a/packages/sdk/src/internal/providers/bailian/mapper.ts +++ b/packages/sdk/src/internal/providers/bailian/mapper.ts @@ -250,7 +250,17 @@ export function mapAgent( } // Tools: builtin_toolkit + mcp_toolkit blocks - const BAILIAN_BUILTINS = new Set(["bash", "read", "write", "edit", "glob", "grep", "download_file"]); + const BAILIAN_BUILTINS = new Set([ + "bash", + "read", + "write", + "edit", + "glob", + "grep", + "web_search", + "web_fetch", + "mark_artifacts", + ]); if (decl.tools) { const toolConfigs = resolveBuiltinTools(decl.tools, { supportedWireNames: BAILIAN_BUILTINS, diff --git a/packages/sdk/tests/e2e/bailian-adapter.test.ts b/packages/sdk/tests/e2e/bailian-adapter.test.ts index 49ba599..53f844c 100644 --- a/packages/sdk/tests/e2e/bailian-adapter.test.ts +++ b/packages/sdk/tests/e2e/bailian-adapter.test.ts @@ -356,6 +356,32 @@ describe("BailianAdapter e2e", () => { expect(result.version).toBe(1); }); + test("createAgent preserves opt-in web and artifact builtin tools", async () => { + const { calls, restore } = mockFetch([{ status: 200, body: AGENT_RESPONSE }]); + cleanup = restore; + await makeAdapter().createAgent( + "helper", + { + model: "qwen3.7-max", + instructions: "Use requested tools.", + tools: { builtin: ["bash", "web_search", "web_fetch", "mark_artifacts"] }, + }, + { skill_ids: [] }, + ); + const body = calls[0]!.body as Record; + const tools = body.tools as Array<{ type: string; default_config: unknown; configs: unknown }>; + expect(tools[0]).toEqual({ + type: "builtin_toolkit", + default_config: { enabled: false }, + configs: [ + { name: "bash", enabled: true }, + { name: "web_search", enabled: true }, + { name: "web_fetch", enabled: true }, + { name: "mark_artifacts", enabled: true }, + ], + }); + }); + test("createAgent uses explicit external skill version without fetching versions", async () => { const externalRefs: ResolvedAgentRefs = { skill_ids: [{ type: "official", skill_id: "pptx", version: "1.0" }], From b2efb191f0227b55c36dc532194f4cdd98561daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=B9=E5=9D=A4?= Date: Tue, 29 Sep 2026 17:51:17 +0800 Subject: [PATCH 3/3] feat(multiagent): add Multi-Agent coordinator support for Qoder and Bailian Managed Agents - Support `multiagent.agents` with project logical names and external Managed Agent references - Validate topology to prevent self-reference, nesting, cycles, and enforce max 20 members - Ensure Qoder members share coordinator's delivery type - Map members to `id` or `template_id` wire references - Allow clearing rosters with `multiagent: null` or empty array - Compare multi-agent drift semantically for consistency - Export remote coordinators to logical names or stable external refs - Do not lifecycle-manage external members - Qoder Forward rejects `{ agent_id }` since roster requires Template ids - Bump versions to 0.8.0 in sdk, cli, playground; 0.0.14 in server - Update CHANGELOGs with minor changes and dependency updates --- .changeset/multi-agent-qoder-bailian.md | 7 ------- apps/server/CHANGELOG.md | 7 +++++++ apps/server/package.json | 2 +- packages/cli/CHANGELOG.md | 11 +++++++++++ packages/cli/package.json | 2 +- packages/playground/CHANGELOG.md | 11 +++++++++++ packages/playground/package.json | 2 +- packages/sdk/CHANGELOG.md | 6 ++++++ packages/sdk/package.json | 2 +- 9 files changed, 39 insertions(+), 11 deletions(-) delete mode 100644 .changeset/multi-agent-qoder-bailian.md diff --git a/.changeset/multi-agent-qoder-bailian.md b/.changeset/multi-agent-qoder-bailian.md deleted file mode 100644 index 93c9bd1..0000000 --- a/.changeset/multi-agent-qoder-bailian.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@openagentpack/sdk": minor -"@openagentpack/cli": minor -"@openagentpack/playground": minor ---- - -Add Multi-Agent coordinator support for Qoder (Managed Agents and Forward Templates) and Bailian Managed Agents. `multiagent.agents` accepts project logical names plus explicit external Managed Agent references (`{ agent_id }`), validates the phase-1 topology (no self/nesting/cycles, max 20 members, Qoder members share the coordinator's delivery type), maps members to `id` or `template_id` wire references, clears rosters via `multiagent: null` or an empty array, compares multi-agent drift semantically, and exports remote coordinators back to logical names or stable external references. External members are not lifecycle-managed, and Qoder Forward rejects `{ agent_id }` because its roster requires Template ids. diff --git a/apps/server/CHANGELOG.md b/apps/server/CHANGELOG.md index 45fcb39..4c92bc0 100644 --- a/apps/server/CHANGELOG.md +++ b/apps/server/CHANGELOG.md @@ -1,5 +1,12 @@ # @openagentpack/server +## 0.0.14 + +### Patch Changes + +- Updated dependencies [3823078] + - @openagentpack/sdk@0.8.0 + ## 0.0.13 ### Patch Changes diff --git a/apps/server/package.json b/apps/server/package.json index 70588cb..22fa339 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@openagentpack/server", - "version": "0.0.13", + "version": "0.0.14", "private": true, "license": "Apache-2.0", "exports": { diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index befd923..019bc3d 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,16 @@ # @openagentpack/cli +## 0.8.0 + +### Minor Changes + +- 3823078: Add Multi-Agent coordinator support for Qoder (Managed Agents and Forward Templates) and Bailian Managed Agents. `multiagent.agents` accepts project logical names plus explicit external Managed Agent references (`{ agent_id }`), validates the phase-1 topology (no self/nesting/cycles, max 20 members, Qoder members share the coordinator's delivery type), maps members to `id` or `template_id` wire references, clears rosters via `multiagent: null` or an empty array, compares multi-agent drift semantically, and exports remote coordinators back to logical names or stable external references. External members are not lifecycle-managed, and Qoder Forward rejects `{ agent_id }` because its roster requires Template ids. + +### Patch Changes + +- Updated dependencies [3823078] + - @openagentpack/sdk@0.8.0 + ## 0.7.3 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 3359e7f..a0492a4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openagentpack/cli", - "version": "0.7.3", + "version": "0.8.0", "description": "Open Agent Pack — Declaratively manage AI agent infrastructure", "license": "Apache-2.0", "keywords": [ diff --git a/packages/playground/CHANGELOG.md b/packages/playground/CHANGELOG.md index 1a370aa..3380f67 100644 --- a/packages/playground/CHANGELOG.md +++ b/packages/playground/CHANGELOG.md @@ -1,5 +1,16 @@ # @openagentpack/playground +## 0.8.0 + +### Minor Changes + +- 3823078: Add Multi-Agent coordinator support for Qoder (Managed Agents and Forward Templates) and Bailian Managed Agents. `multiagent.agents` accepts project logical names plus explicit external Managed Agent references (`{ agent_id }`), validates the phase-1 topology (no self/nesting/cycles, max 20 members, Qoder members share the coordinator's delivery type), maps members to `id` or `template_id` wire references, clears rosters via `multiagent: null` or an empty array, compares multi-agent drift semantically, and exports remote coordinators back to logical names or stable external references. External members are not lifecycle-managed, and Qoder Forward rejects `{ agent_id }` because its roster requires Template ids. + +### Patch Changes + +- Updated dependencies [3823078] + - @openagentpack/sdk@0.8.0 + ## 0.7.3 ### Patch Changes diff --git a/packages/playground/package.json b/packages/playground/package.json index 53f5e51..94499f9 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -1,6 +1,6 @@ { "name": "@openagentpack/playground", - "version": "0.7.3", + "version": "0.8.0", "description": "OpenAgentPack Playground — one-command local web UI for OpenAgentPack", "license": "Apache-2.0", "keywords": [ diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index ab59be9..5dd7fa0 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -1,5 +1,11 @@ # @openagentpack/sdk +## 0.8.0 + +### Minor Changes + +- 3823078: Add Multi-Agent coordinator support for Qoder (Managed Agents and Forward Templates) and Bailian Managed Agents. `multiagent.agents` accepts project logical names plus explicit external Managed Agent references (`{ agent_id }`), validates the phase-1 topology (no self/nesting/cycles, max 20 members, Qoder members share the coordinator's delivery type), maps members to `id` or `template_id` wire references, clears rosters via `multiagent: null` or an empty array, compares multi-agent drift semantically, and exports remote coordinators back to logical names or stable external references. External members are not lifecycle-managed, and Qoder Forward rejects `{ agent_id }` because its roster requires Template ids. + ## 0.7.3 ### Patch Changes diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 6e5e85b..e637afb 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@openagentpack/sdk", - "version": "0.7.3", + "version": "0.8.0", "description": "OpenAgentPack SDK (Node-compatible runtime)", "license": "Apache-2.0", "keywords": [