diff --git a/README.md b/README.md index 6626f1f..b61c4e8 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ Beta testers can install `@openagentpack/cli@beta`; see the [release guide](./do | MCP Server | native | native | native | native | | Memory Store | unsupported | native | unsupported | native | | Multi-Agent | unsupported | unsupported | native | native | -| Deployment | emulated | emulated | native | emulated | +| Deployment | emulated | native | native | emulated | | Session | native | native | native | native | The full capability matrix and per-provider differences live in the [Provider reference](./docs/reference/providers.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index b2314b8..78078e8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -149,7 +149,7 @@ Beta 用户可以安装 `@openagentpack/cli@beta`;固定版本及切回稳定 | MCP Server | native | native | native | native | | Memory Store | unsupported | native | unsupported | native | | Multi-Agent | unsupported | unsupported | native | native | -| Deployment | emulated | emulated | native | emulated | +| Deployment | emulated | native | native | emulated | | Session | native | native | native | native | 完整能力矩阵与各 Provider 差异见 [Provider 参考](./docs/reference/providers.zh-CN.md)。 diff --git a/docs/architecture/how-it-works.md b/docs/architecture/how-it-works.md index afbb75c..0c76937 100644 --- a/docs/architecture/how-it-works.md +++ b/docs/architecture/how-it-works.md @@ -63,4 +63,4 @@ agents state import
Resources (agents, environments, skills…) are **infrastructure** — long-lived, managed by `plan`/`apply`. A **session** is a **runtime** conversation started from an agent. Sessions are managed separately with `agents session` and are not part of the plan/apply lifecycle. -Deployments sit between the two: they are declared as resources but produce runs. On Claude they schedule server-side; on Bailian, Qoder, and Volcengine Ark a `deployment run` expands into a session. +Deployments sit between the two: they are declared as resources but produce runs. On Qoder and Claude they schedule server-side; on Bailian and Volcengine Ark a `deployment run` expands into a session. diff --git a/docs/concepts/agents-as-code.md b/docs/concepts/agents-as-code.md index 909215d..ec64d9a 100644 --- a/docs/concepts/agents-as-code.md +++ b/docs/concepts/agents-as-code.md @@ -19,7 +19,7 @@ Because the declaration is a file, it gets everything a file gets: code review, - The **agent harness** is the provider-managed layer that wraps a model into an agent: knowledge base, skills, MCP wiring, prompt/instructions, vault, deployment, multi-agent orchestration. These are the customer's portable assets. - The **agent infra** is the interchangeable execution substrate beneath the harness — the specific provider (Bailian, Qoder, Claude, Volcengine Ark) that runs the agent. -OpenAgentPack's portability claim is that the same harness declaration can target different agent infra. Portability means the *core declaration* is portable and the per-provider **capability contract** is explicit — unsupported facets degrade gracefully (for example, an emulated `Deployment` on Bailian/Qoder/Volcengine Ark) — not that every feature is identical on every provider. +OpenAgentPack's portability claim is that the same harness declaration can target different agent infra. Portability means the *core declaration* is portable and the per-provider **capability contract** is explicit — unsupported facets degrade gracefully (for example, an emulated `Deployment` on Bailian/Volcengine Ark) — not that every feature is identical on every provider. ## What this enables diff --git a/docs/concepts/sessions-and-deployments.md b/docs/concepts/sessions-and-deployments.md index 7c8befb..d8597d7 100644 --- a/docs/concepts/sessions-and-deployments.md +++ b/docs/concepts/sessions-and-deployments.md @@ -23,7 +23,8 @@ How a deployment *runs* depends on the provider's capability tier: | Provider | Deployment tier | What `agents deployment run` does | |----------|:--------------:|------------------------------------| | Claude | native | schedules server-side through the deployments API | -| Qoder, Bailian, Ark | emulated | expands into a one-shot session at run time | +| Qoder | native | creates a deployment run and associated session | +| Bailian, Ark | emulated | expands into a one-shot session at run time | On the emulated providers, scheduling and outcome rubrics are **not** enforced server-side — use external cron/CI for always-on or scheduled runs. diff --git a/docs/examples.md b/docs/examples.md index e352cb6..fbeffa5 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -52,7 +52,7 @@ agents destroy | MCP Server | native | native | native | native | | Memory Store | unsupported | native | unsupported | native | | Multi-Agent | unsupported | unsupported | native | native | -| Deployment | emulated | emulated | native | emulated | +| Deployment | emulated | native | native | emulated | | Session | native | native | native | native | See [Provider reference](./reference/providers.md) for per-provider configuration and notes. diff --git a/docs/guides/configure-an-agent.zh-CN.md b/docs/guides/configure-an-agent.zh-CN.md index f1d4072..d027351 100644 --- a/docs/guides/configure-an-agent.zh-CN.md +++ b/docs/guides/configure-an-agent.zh-CN.md @@ -460,7 +460,7 @@ Deployment 是介于「定义」与「运行」之间的声明式中间层。它 - **部署层**(Deployment):声明「用哪个 Agent、带哪些绑定、以什么初始事件和调度运行」。 - **运行层**(Session):一次具体的执行实例。 -> Provider 差异:Claude 原生支持 Deployment(对应平台的 deployments API,可服务端调度);百炼、Qoder、火山方舟为**模拟**实现——`apply` 只写本地状态(`remote_id` 为 `null`),`agents deployment run` 时展开为一个 Session。详见 [Provider 参考](../reference/providers.zh-CN.md#模拟emulated资源的能力降级)。 +> Provider 差异:Qoder 和 Claude 原生支持 Deployment(对应平台的 deployments API,可服务端调度);百炼、火山方舟为**模拟**实现——`apply` 只写本地状态(`remote_id` 为 `null`),`agents deployment run` 时展开为一个 Session。详见 [Provider 参考](../reference/providers.zh-CN.md#模拟emulated资源的能力降级)。 ### 定义 Deployment @@ -498,7 +498,7 @@ deployments: - `agent` 必填,引用顶层 `agents` 中定义的 Agent。 - 未声明 `environment` / `vaults` / `memory_stores` 时,**继承所引用 Agent 的声明**;显式声明则覆盖。 -- `schedule.expression` 为 cron 表达式;`timezone` 为必填的 IANA 时区标识。仅 Claude 在服务端执行调度,百炼、Qoder、火山方舟需用外部 cron/CI 触发。 +- `schedule.expression` 为 cron 表达式;`timezone` 为必填的 IANA 时区标识。Qoder 和 Claude 在服务端执行调度,百炼、火山方舟需用外部 cron/CI 触发。 - `initial_events` **必填**(1–50 条),在 Deployment 运行时按顺序投递给 Agent。 ### 初始事件(initial_events) @@ -507,7 +507,7 @@ deployments: |---------|------|------| | `user.message` | `content` | 以用户身份发送的消息 | | `system.message` | `content` | 系统提示(Qoder 上展平为 `user.message`) | -| `user.define_outcome` | `description` / `rubric` / `rubric_file` / `max_iterations` | 定义结果评分目标(仅 Claude 服务端执行评分) | +| `user.define_outcome` | `description` / `rubric` / `rubric_file` / `max_iterations` | 定义结果评分目标(Qoder 和 Claude 服务端执行评分) | ### 资源(resources) @@ -515,9 +515,9 @@ Deployment 运行时可挂载的资源: | 资源类型 | 字段 | 说明 | |---------|------|------| -| `file` | `file_id` / `source` / `mount_path` | 文件资源;`file_id` 引用已上传文件;`source` 为本地路径,Claude 在 `apply` 时上传、Qoder 在 `run` 时上传 | +| `file` | `file_id` / `source` / `mount_path` | 文件资源;`file_id` 引用已上传文件;`source` 为本地路径,Qoder 和 Claude 在 `apply` 时上传 | | `memory_store` | `memory_store` / `access` / `instructions` | 引用 Memory Store;`access` 为 `read_write`(默认)或 `read_only` | -| `github_repository` | `url` / `checkout` / `mount_path` / `authorization_token` | 检出 Git 仓库(仅 Claude) | +| `github_repository` | `url` / `checkout` / `mount_path` / `authorization_token` | 检出 Git 仓库(Qoder 和 Claude) | ### 运行与查看 @@ -669,7 +669,7 @@ Session 的架构遵循 Serverless Framework 模式:**定义管理**(`plan/a | `memory_stores` | string[] | 否 | 覆盖 Agent 的 Memory Store | | `resources` | array | 否 | 运行时挂载的资源(file / memory_store / github_repository) | | `initial_events` | array | 是 | 运行时投递的初始事件(user.message / system.message / user.define_outcome),1–50 条 | -| `schedule` | object | 否 | cron 调度(`expression` + 必填 `timezone`),仅 Claude 服务端执行 | +| `schedule` | object | 否 | cron 调度(`expression` + 必填 `timezone`),Qoder 和 Claude 服务端执行 | | `description` | string | 否 | 描述 | | `provider` | string | 否 | 指定 Provider,覆盖 defaults | | `metadata` | Record | 否 | 自定义元数据 | diff --git a/docs/guides/deploy-to-qoder.md b/docs/guides/deploy-to-qoder.md index 97f0261..99120b0 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** but no multi-agent or server-side deployment primitive. +Qoder is a managed-agent platform with native **memory stores** and **deployments**, but no multi-agent primitive. ## Provider configuration @@ -20,11 +20,10 @@ providers: | Feature | Tier | |---------|:----:| -| Environment, Vault, Skill, Agent, MCP Server, Memory Store, Session | native | +| Environment, Vault, Skill, Agent, MCP Server, Memory Store, Deployment, Session | native | | Multi-Agent | unsupported | -| Deployment | emulated | -A `deployment run` on Qoder expands into a one-shot session at run time; scheduling and outcome rubrics are not enforced server-side — use external cron/CI for scheduled runs. +A `deployment run` on Qoder creates a native Deployment Run and associated Session. Cron schedules run server-side. ## Tool naming diff --git a/docs/guides/manage-deployments.md b/docs/guides/manage-deployments.md index 6774953..c67f9c6 100644 --- a/docs/guides/manage-deployments.md +++ b/docs/guides/manage-deployments.md @@ -67,12 +67,13 @@ agents deployment run # trigger a run | Provider | Deployment tier | What `deployment run` does | |----------|:--------------:|----------------------------| | Claude | native | schedules server-side through the deployments API | -| Qoder, Bailian, Ark | emulated | expands into a one-shot session at run time | +| Qoder | native | creates a deployment run and associated session | +| Bailian, Ark | emulated | expands into a one-shot session at run time | On the emulated providers, scheduling and outcome rubrics are **not** enforced server-side — use external cron/CI for always-on or scheduled runs. ## Examples - Native deployment + outcome rubric: [`examples/claude/deployment/`](../../examples/claude/deployment/) -- Emulated deployment + memory store: [`examples/qoder/deployment/`](../../examples/qoder/deployment/) +- Native deployment + memory store: [`examples/qoder/deployment/`](../../examples/qoder/deployment/) - Emulated deployment + file resources: [`examples/bailian/deployment/`](../../examples/bailian/deployment/) and [`examples/ark/deployment/`](../../examples/ark/deployment/) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f449fba..bda61bd 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -120,7 +120,7 @@ Manage scheduled / triggered deployments. |------------|-------------| | `deployment list` | List deployments tracked in state. | | `deployment get ` | Show a deployment's status and resolved bindings. | -| `deployment run ` | Trigger a deployment run (native on Claude, emulated as a session on Bailian/Qoder/Volcengine Ark). | +| `deployment run ` | Trigger a deployment run (native on Qoder/Claude, emulated as a session on Bailian/Volcengine Ark). | ## `agents models` diff --git a/docs/reference/providers.md b/docs/reference/providers.md index d8be961..f0be425 100644 --- a/docs/reference/providers.md +++ b/docs/reference/providers.md @@ -15,7 +15,7 @@ OpenAgentPack targets multiple agent platforms behind one declarative config. Ea | MCP Server | native | native | native | native | Bailian uses official managed servers referenced by name. | | Memory Store | unsupported | native | unsupported | native | Qoder and Ark are implemented. Claude's upstream API now has Memory Stores, but its OpenAgentPack adapter does not yet. | | Multi-Agent | unsupported | unsupported | native | native | Coordinator topology is available on Claude and Volcengine Ark. | -| Deployment | emulated | emulated | native | emulated | Non-Claude providers expand a deployment into a session at `run` time. | +| Deployment | emulated | native | native | emulated | Qoder and Claude use native deployments; Bailian and Ark expand a deployment into a session at `run` time. | | Session | native | native | native | native | Runtime sessions are native on every provider. | - **native** — the provider supports the feature directly. @@ -47,7 +47,7 @@ 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. -- **Qoder:** tool names are translated from the lowercase config vocabulary to PascalCase. Session sends return a cursor, enabling resumable event consumption. `system.message` is flattened to `user.message` when running an emulated deployment. +- **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. - **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 1e16016..2a7558f 100644 --- a/docs/reference/providers.zh-CN.md +++ b/docs/reference/providers.zh-CN.md @@ -34,7 +34,7 @@ OpenAgentPack 通过 Provider 适配器与不同的 AI Agent 平台交互。每 | MCP Server | native | native | native | native | 通过 Agent 的 MCP 配置挂载 | | Memory Store | unsupported | native | unsupported | native | Qoder、方舟已接入;Claude 上游已有 Memory Store,但 OpenAgentPack Adapter 尚未实现 | | Multi-Agent | unsupported | unsupported | native | native | Claude 与 火山方舟 支持 coordinator | -| Deployment | emulated | emulated | native | emulated | 非 Claude Provider 在 `run` 时展开为 Session | +| Deployment | emulated | native | native | emulated | Qoder 和 Claude 使用原生 Deployment;百炼和火山方舟在 `run` 时展开为 Session | | Session | native | native | native | native | 四者均原生支持 | ### Adapter 实现能力对照表 @@ -60,7 +60,7 @@ OpenAgentPack 通过 Provider 适配器与不同的 AI Agent 平台交互。每 #### Provider 特有实现与限制 - **百炼**:Skill 通过 Files API 上传并支持扫描状态轮询;Agent 更新会生成平台侧版本;官方 MCP Server 按名称引用。 -- **Qoder**:配置中的小写工具名会转换为 PascalCase;Session 发送返回游标,可恢复事件消费;模拟 Deployment 中的 `system.message` 会展平为 `user.message`。 +- **Qoder**:配置中的小写工具名会转换为 PascalCase;Session 发送返回游标,可恢复事件消费;Deployment 为原生资源,支持手动或定时运行。 - **Claude**:Deployment 是原生资源,具有服务端生命周期;当前只有 Claude Adapter 会在 `sync` 时下载远端 Skill 包。 - **火山方舟**:经本项目验证的 Skill API 行为仅支持创建、按 ID 查询和挂载。更新会重新上传,无法枚举和原地更新,删除为 best-effort;Deployment 由 Session 模拟。 @@ -113,7 +113,7 @@ OpenAgentPack 通过 Provider 适配器与不同的 AI Agent 平台交互。每 | Skill | existence | existence | existence | existence | 可发现缺失/删除,不比较包内容 | | Vault | existence | existence | existence | existence | 凭证内容通常不可读回,不比较内容 | | Memory Store | unsupported | existence | unsupported | existence | 可发现资源缺失 | -| Deployment | unsupported | unsupported | native 路径待验证 | unsupported | emulated Deployment 为本地记录 | +| Deployment | unsupported | native | native 路径待验证 | unsupported | 百炼和火山方舟的 emulated Deployment 为本地记录 | Claude 的 drift detection 接口路径已预留;本仓库中的 live baseline 因 Anthropic API 账号余额不足未完成 Agent 创建验证。 @@ -139,7 +139,7 @@ qoder.multiagent.unsupported: ### 模拟(emulated)资源的能力降级 -`emulated` 等级表示 Provider 没有对应的原生原语,OpenAgentPack 通过其他原语间接实现。Deployment 在 百炼、Qoder、火山方舟 上为模拟实现:`apply` 时**不调用**部署 API(状态记录的 `remote_id` 为 `null`),而是在 `agents deployment run` 时展开为一个 Session 并回放 `initial_events`。 +`emulated` 等级表示 Provider 没有对应的原生原语,OpenAgentPack 通过其他原语间接实现。Deployment 在百炼和火山方舟上为模拟实现:`apply` 时**不调用**部署 API(状态记录的 `remote_id` 为 `null`),而是在 `agents deployment run` 时展开为一个 Session 并回放 `initial_events`。 部分子特性在 emulated Provider 上无法在服务端执行。`plan`/`apply` 阶段会输出**警告**(不阻断部署),`run` 时尽力降级: @@ -150,17 +150,15 @@ qoder.multiagent.unsupported: | `resources` 中的 `github_repository` | 不自动检出仓库 | 在 Session 内克隆仓库 | | `resources` 中 `memory_store` 的 `access` / `instructions` | 忽略,按默认访问挂载 | — | -此外,`system.message` 事件在 Qoder 上会被展平为 `user.message`(Qoder 仅支持 `user.message` 作为出站事件),其内容仍会送达 Agent。 - 示例诊断输出: ``` -⚠ qoder.deployment.schedule_unsupported - Resource: deployment.daily-report (qoder) +⚠ bailian.deployment.schedule_unsupported + Resource: deployment.daily-report (bailian) Schedules are not enforced server-side on this provider; trigger runs via external cron/CI. -⚠ qoder.deployment.define_outcome_unsupported - Resource: deployment.daily-report (qoder) +⚠ bailian.deployment.define_outcome_unsupported + Resource: deployment.daily-report (bailian) Outcome rubrics (user.define_outcome) are not enforced server-side on this provider; the run executes without rubric grading. ``` diff --git a/examples/README.md b/examples/README.md index 7c15550..496a2cc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -32,7 +32,7 @@ examples/ │ ├── with-vault/ vault only │ ├── vault-only/ vault-only project │ ├── multi-provider/ same agent on both Claude + Qoder -│ ├── deployment/ schedule + memory_store (emulated) +│ ├── deployment/ schedule + memory_store (native) │ ├── bailian-cli/ Bailian CLI integration │ └── full/ Qoder full-feature stack ├── ark/ Volcengine Ark provider (Managed Agents, doubao) @@ -59,7 +59,7 @@ examples/ | MCP Server | native | native | native | native | Bailian uses official managed servers referenced by name. | | Memory Store | unsupported | native | unsupported | native | Qoder and Volcengine Ark. | | Multi-Agent | unsupported | unsupported | native | native | Claude and Volcengine Ark support coordinator. | -| Deployment | emulated | emulated | native | emulated | Claude schedules server-side; others expand into a session at `run` time. | +| Deployment | emulated | native | native | emulated | Qoder and Claude schedule server-side; Bailian and Ark expand into a session at `run` time. | | Session | native | native | native | native | All four support runtime sessions. | ## Tool naming differences diff --git a/examples/qoder/deployment/agents.yaml b/examples/qoder/deployment/agents.yaml index 69a5df2..bc1d360 100644 --- a/examples/qoder/deployment/agents.yaml +++ b/examples/qoder/deployment/agents.yaml @@ -36,14 +36,14 @@ agents: builtin: [Read, Glob, Grep, WebSearch] memory_stores: [project-kb] -# Deployment is emulated on Qoder: `apply` only records local state -# (remote_id = null); `agents deployment run` materializes it into a Session. +# Deployment is native on Qoder: `apply` creates a remote Deployment, and +# `agents deployment run` creates a Deployment Run with an associated Session. # The schedule, define_outcome rubric, and memory_store access modifier below # are not enforced server-side and will surface warning diagnostics on plan. deployments: daily-report: agent: reporter - description: "Daily report (emulated -> Session on run)" + description: "Daily report (native Deployment Run)" schedule: expression: "0 9 * * *" timezone: UTC diff --git a/packages/sdk/src/internal/providers/qoder/adapter.ts b/packages/sdk/src/internal/providers/qoder/adapter.ts index 5287d2c..00e4cf3 100644 --- a/packages/sdk/src/internal/providers/qoder/adapter.ts +++ b/packages/sdk/src/internal/providers/qoder/adapter.ts @@ -55,7 +55,7 @@ import { fileToDecl, mapAgent, mapCredential, - mapDeploymentToSession, + mapDeployment, mapEnvironment, mapMemoryStore, mapSendMessage, @@ -358,77 +358,76 @@ export class QoderAdapter implements ProviderAdapter { } async createDeployment( - _name: string, - _decl: DeploymentDecl, - _refs: ResolvedDeploymentRefs, - _basePath: string, + name: string, + decl: DeploymentDecl, + refs: ResolvedDeploymentRefs, + basePath: string, ): Promise { - // Emulated: Qoder has no deployments endpoint. The deployment is recorded in - // state with remote_id = null and materialized into a session at run time. - return { id: null, type: "deployment" }; + const uploaded = await this.uploadDeploymentFiles(decl, basePath); + const body = mapDeployment(name, decl, refs, this.projectName, uploaded); + const res = (await this.client.post("/deployments", body)) as Record; + return toRemoteResource(res); } async updateDeployment( - _id: string, - _name: string, - _decl: DeploymentDecl, - _refs: ResolvedDeploymentRefs, - _basePath: string, + id: string, + name: string, + decl: DeploymentDecl, + refs: ResolvedDeploymentRefs, + basePath: string, ): Promise { - return { id: null, type: "deployment" }; + const uploaded = await this.uploadDeploymentFiles(decl, basePath); + const body = mapDeployment(name, decl, refs, this.projectName, uploaded); + const res = (await this.client.post(`/deployments/${id}`, body)) as Record; + return toRemoteResource(res); } - async deleteDeployment(_id: string): Promise { - // Emulated: no remote object to delete. + async deleteDeployment(id: string): Promise { + await this.client.post(`/deployments/${id}/archive`, {}); } async runDeployment(ctx: DeploymentContext): Promise { - // Emulated run: upload file resources, create a session bound to the - // deployment's agent/environment/vault/memory_stores, then replay messages. - const fileIds: string[] = []; - for (const r of ctx.decl.resources ?? []) { - if (r.type === "file") { - if (r.file_id) { - fileIds.push(r.file_id); - } else if (r.source) { - fileIds.push(await this.uploadSessionFile(r.source, ctx.basePath)); - } - } - } - - const body = mapDeploymentToSession(ctx.decl, ctx.refs, fileIds); - const sessionRes = (await this.client.post("/sessions", body)) as Record; - const sessionId = sessionRes.id as string; - - // Qoder only supports `user.message` as an outbound event, so a - // system.message is delivered as a user.message (its content still reaches - // the agent). define_outcome events are dropped (warned at plan time). - const events = ctx.decl.initial_events - .filter((e) => e.type === "user.message" || e.type === "system.message") - .map((e) => ({ - type: "user.message", - content: [{ type: "text", text: (e as { content: string }).content }], - })); - - if (events.length) { - await this.client.post(`/sessions/${sessionId}/events`, { events }); + if (!ctx.id) { + throw new UserError(`Deployment '${ctx.name}' has no remote id; run \`agents apply\` first.`); } - - return { session_id: sessionId }; + const res = (await this.client.post(`/deployments/${ctx.id}/run`, {})) as Record; + return { + run_id: res.id as string | undefined, + session_id: (res.session_id as string | null) ?? null, + error: (res.error as { type: string; message: string } | null | undefined) ?? undefined, + }; } async getDeployment(ctx: DeploymentContext): Promise { - // Emulated: no remote object. Report the local record and the session that - // `run` would materialize. - const plan = mapDeploymentToSession(ctx.decl, ctx.refs, []); + if (!ctx.id) { + throw new UserError(`Deployment '${ctx.name}' has no remote id; run \`agents apply\` first.`); + } + const res = (await this.client.get(`/deployments/${ctx.id}`)) as Record; + const sched = res.schedule as Record | null | undefined; return { - id: ctx.id, - status: "emulated (local)", - schedule: ctx.decl.schedule, - attributes: { materialization_plan: plan }, + id: res.id as string, + status: (res.status as string) ?? "unknown", + paused_reason: res.paused_reason as { type: string; error?: { type: string } } | undefined, + schedule: sched + ? { + expression: sched.expression as string, + timezone: sched.timezone as string, + } + : undefined, + attributes: res, }; } + private async uploadDeploymentFiles(decl: DeploymentDecl, basePath: string): Promise> { + const map = new Map(); + for (const r of decl.resources ?? []) { + if (r.type === "file" && !r.file_id && r.source && !map.has(r.source)) { + map.set(r.source, await this.uploadSessionFile(r.source, basePath)); + } + } + return map; + } + private async uploadSessionFile(source: string, basePath: string): Promise { const fullPath = resolve(dirname(basePath), source); const content = readFileSync(fullPath); diff --git a/packages/sdk/src/internal/providers/qoder/capabilities.ts b/packages/sdk/src/internal/providers/qoder/capabilities.ts index 68a691c..e0099d5 100644 --- a/packages/sdk/src/internal/providers/qoder/capabilities.ts +++ b/packages/sdk/src/internal/providers/qoder/capabilities.ts @@ -13,10 +13,8 @@ export const QODER_CAPABILITIES: ProviderCapabilities = { remediation: "deploy agents independently and orchestrate via MCP", }, deployment: { - tier: "emulated", - reason: "no deployment primitive on Qoder; expanded into a session at run time", - remediation: - "scheduling and outcome rubrics are not enforced server-side — use external cron/CI for always-on or scheduled runs", + tier: "native", + reason: "deployments API with scheduled and manual runs", }, session: { tier: "native", reason: "sessions API" }, }; diff --git a/packages/sdk/src/internal/providers/qoder/mapper.ts b/packages/sdk/src/internal/providers/qoder/mapper.ts index 1747304..8fb9ee2 100644 --- a/packages/sdk/src/internal/providers/qoder/mapper.ts +++ b/packages/sdk/src/internal/providers/qoder/mapper.ts @@ -4,6 +4,7 @@ import type { CredentialDecl, DeploymentDecl, EnvironmentDecl, + InitialEventDecl, MemoryStoreDecl, ModelSpec, VaultDecl, @@ -224,6 +225,110 @@ export function mapMemoryStore(name: string, decl: MemoryStoreDecl): unknown { }; } +export function mapDeployment( + name: string, + decl: DeploymentDecl, + refs: ResolvedDeploymentRefs, + projectName?: string, + uploadedFiles?: Map, +): unknown { + const body: Record = { + name, + agent: + refs.agent_version !== undefined + ? { id: refs.agent_id, type: "agent", version: refs.agent_version } + : refs.agent_id, + environment_id: refs.environment_id, + initial_events: mapDeploymentInitialEvents(decl.initial_events), + }; + + if (refs.vault_ids.length) body.vault_ids = refs.vault_ids; + + const resources = mapDeploymentResources(decl, refs, uploadedFiles); + if (resources.length) body.resources = resources; + + if (decl.schedule) { + body.schedule = { + type: "cron", + expression: decl.schedule.expression, + timezone: decl.schedule.timezone, + }; + } + + if (decl.description) body.description = decl.description; + + if (projectName) { + body.metadata = injectMetadata(decl.metadata, projectName, name); + } else if (decl.metadata) { + body.metadata = decl.metadata; + } + + return body; +} + +function mapDeploymentInitialEvents(events: InitialEventDecl[]): unknown[] { + return events.map((ev) => { + if (ev.type === "user.message" || ev.type === "system.message") { + return { type: ev.type, content: [{ type: "text", text: ev.content }] }; + } + const out: Record = { type: "user.define_outcome" }; + if (ev.description) out.description = ev.description; + if (ev.rubric) { + out.rubric = { type: "text", content: ev.rubric }; + } else if (ev.rubric_file) { + out.rubric = { type: "file", file_id: ev.rubric_file }; + } + if (ev.max_iterations !== undefined) out.max_iterations = ev.max_iterations; + return out; + }); +} + +function mapDeploymentResources( + decl: DeploymentDecl, + refs: ResolvedDeploymentRefs, + uploadedFiles?: Map, +): unknown[] { + const resources: unknown[] = []; + const seenStores = new Set(); + + for (const r of decl.resources ?? []) { + if (r.type === "file") { + const fileId = r.file_id ?? (r.source ? uploadedFiles?.get(r.source) : undefined); + if (fileId) { + const entry: Record = { type: "file", file_id: fileId }; + if (r.mount_path) entry.mount_path = r.mount_path; + resources.push(entry); + } + } else if (r.type === "github_repository") { + const entry: Record = { type: "github_repository", url: r.url }; + if (r.authorization_token) entry.authorization_token = r.authorization_token; + if (r.checkout?.branch) entry.checkout = { type: "branch", name: r.checkout.branch }; + else if (r.checkout?.commit) entry.checkout = { type: "commit", sha: r.checkout.commit }; + if (r.mount_path) entry.mount_path = r.mount_path; + resources.push(entry); + } else if (r.type === "memory_store") { + const id = refs.memory_store_ids[r.memory_store]; + if (id && !seenStores.has(id)) { + seenStores.add(id); + const entry: Record = { type: "memory_store", memory_store_id: id }; + if (r.access) entry.access = r.access; + if (r.instructions) entry.instructions = r.instructions; + resources.push(entry); + } + } + } + + for (const m of decl.memory_stores ?? []) { + const id = refs.memory_store_ids[m]; + if (id && !seenStores.has(id)) { + seenStores.add(id); + resources.push({ type: "memory_store", memory_store_id: id }); + } + } + + return resources; +} + export function mapAgent( name: string, decl: AgentDecl, diff --git a/packages/sdk/tests/unit/deployment.test.ts b/packages/sdk/tests/unit/deployment.test.ts index 6720d0a..0a8f938 100644 --- a/packages/sdk/tests/unit/deployment.test.ts +++ b/packages/sdk/tests/unit/deployment.test.ts @@ -114,23 +114,63 @@ describe("resolveDeploymentRefs", () => { }); }); -describe("Qoder emulated deployment CRUD", () => { - const adapter = new QoderAdapter("pt-test-dummy"); - const decl = { agent: "x" } as DeploymentDecl; - const refs = {} as ResolvedDeploymentRefs; +describe("Qoder native deployment CRUD", () => { + function makeAdapter() { + const calls: Array<{ method: string; path: string; body?: unknown }> = []; + const adapter = new QoderAdapter("pt-test-dummy", undefined, "proj") as QoderAdapter & { + client: { + post: (path: string, body: unknown) => Promise>; + get: (path: string) => Promise>; + }; + }; + adapter.client = { + async post(path, body) { + calls.push({ method: "post", path, body }); + if (path.endsWith("/run")) return { id: "drun_1", type: "deployment_run", session_id: "sess_1", error: null }; + return { id: "dep_1", type: "deployment", status: "active" }; + }, + async get(path) { + calls.push({ method: "get", path }); + return { id: "dep_1", type: "deployment", status: "active", schedule: null }; + }, + }; + return { adapter, calls }; + } + + const decl: DeploymentDecl = { agent: "x", initial_events: [{ type: "user.message", content: "run" }] }; + const refs: ResolvedDeploymentRefs = { + agent_id: "agent_1", + environment_id: "env_1", + vault_ids: [], + memory_store_ids: {}, + }; - test("createDeployment records a local resource with a null remote id (no HTTP)", async () => { + test("createDeployment calls the native deployments endpoint", async () => { + const { adapter, calls } = makeAdapter(); const res = await adapter.createDeployment("d", decl, refs, "/tmp/agents.yaml"); - expect(res).toEqual({ id: null, type: "deployment" }); + expect(res).toEqual({ id: "dep_1", type: "deployment" }); + expect(calls[0]).toMatchObject({ method: "post", path: "/deployments" }); + expect(calls[0].body).toMatchObject({ name: "d", agent: "agent_1", environment_id: "env_1" }); + }); + + test("updateDeployment posts a merge update to the remote deployment", async () => { + const { adapter, calls } = makeAdapter(); + const res = await adapter.updateDeployment("dep_1", "d", decl, refs, "/tmp/agents.yaml"); + expect(res).toEqual({ id: "dep_1", type: "deployment" }); + expect(calls[0]).toMatchObject({ method: "post", path: "/deployments/dep_1" }); }); - test("updateDeployment also returns a null remote id", async () => { - const res = await adapter.updateDeployment("ignored", "d", decl, refs, "/tmp/agents.yaml"); - expect(res).toEqual({ id: null, type: "deployment" }); + test("deleteDeployment archives the remote deployment", async () => { + const { adapter, calls } = makeAdapter(); + await adapter.deleteDeployment("dep_1"); + expect(calls[0]).toMatchObject({ method: "post", path: "/deployments/dep_1/archive" }); }); - test("deleteDeployment is a no-op", async () => { - await expect(adapter.deleteDeployment("ignored")).resolves.toBeUndefined(); + test("runDeployment triggers a native deployment run", async () => { + const { adapter, calls } = makeAdapter(); + const result = await adapter.runDeployment({ name: "d", id: "dep_1", decl, refs, basePath: "/tmp/agents.yaml" }); + expect(result).toEqual({ run_id: "drun_1", session_id: "sess_1", error: undefined }); + expect(calls[0]).toMatchObject({ method: "post", path: "/deployments/dep_1/run" }); }); }); @@ -157,15 +197,13 @@ describe("deployment plan / diff", () => { expect(idxAgent).toBeLessThan(idxDep); }); - test("emulated provider (qoder) warns about unsupported sub-features; native (claude) does not", async () => { + test("native deployment providers do not warn about emulated sub-features", async () => { const { config } = await loadConfig(resolve(FIXTURES, "deployment.yaml")); const plan = await buildPlan(config, { resources: [] }); const codes = plan.diagnostics.filter((d) => d.severity === "warning").map((d) => d.code); - expect(codes).toContain("qoder.deployment.schedule_unsupported"); - expect(codes).toContain("qoder.deployment.define_outcome_unsupported"); - expect(codes).toContain("qoder.deployment.github_repository_unsupported"); + expect(codes.some((c) => c.startsWith("qoder.deployment."))).toBe(false); expect(codes.some((c) => c.startsWith("claude.deployment."))).toBe(false); }); diff --git a/packages/sdk/tests/unit/map-deployment.test.ts b/packages/sdk/tests/unit/map-deployment.test.ts index 928bd80..4c0b933 100644 --- a/packages/sdk/tests/unit/map-deployment.test.ts +++ b/packages/sdk/tests/unit/map-deployment.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from "bun:test"; import { mapDeploymentToSession as mapBailianDeploymentToSession } from "../../src/internal/providers/bailian/mapper.ts"; import { mapDeployment } from "../../src/internal/providers/claude/mapper.ts"; import type { ResolvedDeploymentRefs } from "../../src/internal/providers/interface.ts"; -import { mapDeploymentToSession } from "../../src/internal/providers/qoder/mapper.ts"; +import { + mapDeploymentToSession, + mapDeployment as mapQoderDeployment, +} from "../../src/internal/providers/qoder/mapper.ts"; import type { DeploymentDecl } from "../../src/internal/types/config.ts"; function fullRefs(): ResolvedDeploymentRefs { @@ -185,6 +188,74 @@ describe("Qoder mapDeploymentToSession", () => { }); }); +describe("Qoder mapDeployment", () => { + test("full decl produces a native deployment body", () => { + const decl: DeploymentDecl = { + agent: "researcher", + agent_version: 3, + description: "Daily report", + schedule: { expression: "0 9 * * *", timezone: "Asia/Shanghai" }, + initial_events: [ + { type: "user.message", content: "Run the daily report" }, + { type: "system.message", content: "You are punctual" }, + { + type: "user.define_outcome", + description: "Grade", + rubric: "Must include charts", + max_iterations: 5, + }, + ], + memory_stores: ["notes"], + resources: [ + { type: "file", source: "./local.txt", mount_path: "/data/local" }, + { + type: "github_repository", + url: "https://github.com/acme/repo", + checkout: { branch: "main" }, + mount_path: "/repo", + }, + { + type: "memory_store", + memory_store: "archive", + access: "read_only", + instructions: "ref only", + }, + ], + }; + + const uploaded = new Map([["./local.txt", "file_uploaded"]]); + const body = mapQoderDeployment("daily-report", decl, fullRefs(), "myproj", uploaded) as Record; + + expect(body.name).toBe("daily-report"); + expect(body.agent).toEqual({ id: "agent_123", type: "agent", version: 3 }); + expect(body.environment_id).toBe("env_456"); + expect(body.initial_events).toEqual([ + { type: "user.message", content: [{ type: "text", text: "Run the daily report" }] }, + { type: "system.message", content: [{ type: "text", text: "You are punctual" }] }, + { + type: "user.define_outcome", + description: "Grade", + rubric: { type: "text", content: "Must include charts" }, + max_iterations: 5, + }, + ]); + expect(body.resources).toEqual([ + { type: "file", file_id: "file_uploaded", mount_path: "/data/local" }, + { + type: "github_repository", + url: "https://github.com/acme/repo", + checkout: { type: "branch", name: "main" }, + mount_path: "/repo", + }, + { type: "memory_store", memory_store_id: "ms_2", access: "read_only", instructions: "ref only" }, + { type: "memory_store", memory_store_id: "ms_1" }, + ]); + expect(body.schedule).toEqual({ type: "cron", expression: "0 9 * * *", timezone: "Asia/Shanghai" }); + expect(body.vault_ids).toEqual(["vault_a"]); + expect(body.metadata).toEqual({ "agents.project": "myproj", "agents.resource": "daily-report" }); + }); +}); + describe("Bailian mapDeploymentToSession", () => { test("preserves mount_path for file resources", () => { const decl: DeploymentDecl = {