diff --git a/.changeset/bailian-native-deployments.md b/.changeset/bailian-native-deployments.md
new file mode 100644
index 0000000..3a60a55
--- /dev/null
+++ b/.changeset/bailian-native-deployments.md
@@ -0,0 +1,5 @@
+---
+"@openagentpack/sdk": minor
+---
+
+Bailian: implement native Deployment support against the Agent Studio `/deployments` API (create, get, list, update, archive, run, pause/unpause), replacing the previous emulated session expansion. Deployment schedules now run server-side; `user.define_outcome` events and `github_repository` resources are dropped from the deployment payload and surface a warning on plan.
diff --git a/README.md b/README.md
index 89c0c46..ddd6dbb 100644
--- a/README.md
+++ b/README.md
@@ -147,7 +147,7 @@ Beta testers can install `@openagentpack/cli@beta`; see the [release guide](./do
| MCP Server | native | native | native | native |
| Memory Store | unsupported | native | native | native |
| Multi-Agent | unsupported | unsupported | native | native |
-| Deployment | emulated | native | native | emulated |
+| Deployment | native | 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 b316783..b941e1e 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -147,7 +147,7 @@ Beta 用户可以安装 `@openagentpack/cli@beta`;固定版本及切回稳定
| MCP Server | native | native | native | native |
| Memory Store | unsupported | native | native | native |
| Multi-Agent | unsupported | unsupported | native | native |
-| Deployment | emulated | native | native | emulated |
+| Deployment | native | native | native | emulated |
| Session | native | native | native | native |
完整能力矩阵与各 Provider 差异见 [Provider 参考](./docs/reference/providers.zh-CN.md)。
diff --git a/bun.lock b/bun.lock
index 10aaa7a..f8b12e8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -131,7 +131,7 @@
"esbuild": "0.28.1",
"fast-equals": "5.3.3",
"js-yaml": "4.3.1",
- "nanoid": "3.3.17",
+ "nanoid": "3.3.18",
"postcss": "8.5.23",
},
"packages": {
@@ -831,7 +831,7 @@
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
- "nanoid": ["nanoid@3.3.17", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="],
+ "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
diff --git a/docs/architecture/how-it-works.md b/docs/architecture/how-it-works.md
index 0c76937..006fa4c 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 Qoder and Claude they schedule server-side; on Bailian and Volcengine Ark a `deployment run` expands into a session.
+Deployments sit between the two: they are declared as resources but produce runs. On Bailian, Qoder, and Claude they schedule server-side; on 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 ec64d9a..13f15c6 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/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 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 d8597d7..4f662ea 100644
--- a/docs/concepts/sessions-and-deployments.md
+++ b/docs/concepts/sessions-and-deployments.md
@@ -24,9 +24,10 @@ How a deployment *runs* depends on the provider's capability tier:
|----------|:--------------:|------------------------------------|
| Claude | native | schedules server-side through the deployments API |
| Qoder | native | creates a deployment run and associated session |
-| Bailian, Ark | emulated | expands into a one-shot session at run time |
+| Bailian | native | triggers a server-side run through the deployments API |
+| 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.
+On Ark (the emulated provider), scheduling and outcome rubrics are **not** enforced server-side — use external cron/CI for always-on or scheduled runs.
## The lifecycle in one picture
@@ -34,7 +35,7 @@ On the emulated providers, scheduling and outcome rubrics are **not** enforced s
agents.yaml ──plan/apply──▶ managed resources (agent, environment, …)
│
└─session create/run──▶ runtime session
- └─deployment run──────▶ runtime session (emulated) or scheduled run (native)
+ └─deployment run──────▶ scheduled run (native) or runtime session (emulated on Ark)
```
Next: [Run sessions](../guides/run-sessions.md) and [Manage deployments](../guides/manage-deployments.md).
diff --git a/docs/examples.md b/docs/examples.md
index d1edbe2..5a5102c 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -53,7 +53,7 @@ agents destroy
| MCP Server | native | native | native | native |
| Memory Store | unsupported | native | native | native |
| Multi-Agent | unsupported | unsupported | native | native |
-| Deployment | emulated | native | native | emulated |
+| Deployment | native | 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 3610281..344ac32 100644
--- a/docs/guides/configure-an-agent.zh-CN.md
+++ b/docs/guides/configure-an-agent.zh-CN.md
@@ -484,7 +484,7 @@ Deployment 是介于「定义」与「运行」之间的声明式中间层。它
- **部署层**(Deployment):声明「用哪个 Agent、带哪些绑定、以什么初始事件和调度运行」。
- **运行层**(Session):一次具体的执行实例。
-> Provider 差异:Qoder 和 Claude 原生支持 Deployment(对应平台的 deployments API,可服务端调度);百炼、火山方舟为**模拟**实现——`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。百炼上 `user.define_outcome` 事件和 `github_repository` 资源不在部署 payload 内,plan 时会输出警告。详见 [Provider 参考](../reference/providers.zh-CN.md#原生-deployment-的-payload-裁剪)。
### 定义 Deployment
diff --git a/docs/guides/deploy-to-bailian.md b/docs/guides/deploy-to-bailian.md
index 0a3352d..32ac5ee 100644
--- a/docs/guides/deploy-to-bailian.md
+++ b/docs/guides/deploy-to-bailian.md
@@ -26,11 +26,11 @@ providers:
| Environment, Vault, Skill, Agent, MCP Server, Session | native |
| Memory Store | unsupported |
| Multi-Agent | unsupported |
-| Deployment | emulated |
+| Deployment | native |
- Skills upload as a zip via the Files API (two-step).
- MCP servers are **official managed servers** referenced by `name` (no vault needed for them).
-- `deployment run` expands into a one-shot session; scheduling/outcome rubrics are not enforced server-side.
+- Deployments are native: `apply` creates the remote deployment, `schedule` runs server-side (cron + timezone), and `deployment run` triggers a server-side run. `user.define_outcome` events and `github_repository` resources are not part of the deployment payload and surface a warning on plan.
## Minimal agent
diff --git a/docs/guides/manage-deployments.md b/docs/guides/manage-deployments.md
index 3b5486d..3b3ed16 100644
--- a/docs/guides/manage-deployments.md
+++ b/docs/guides/manage-deployments.md
@@ -73,12 +73,14 @@ Qoder deployments may also declare `environment_variables` as a semicolon- or ne
|----------|:--------------:|----------------------------|
| Claude | native | schedules server-side through the deployments API |
| Qoder | native | creates a deployment run and associated session |
-| Bailian, Ark | emulated | expands into a one-shot session at run time |
+| Bailian | native | triggers a server-side run through the deployments API |
+| 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.
+On Ark (the emulated provider), scheduling and outcome rubrics are **not** enforced server-side — use external cron/CI for always-on or scheduled runs. On Bailian, `user.define_outcome` events and `github_repository` resources are dropped from the deployment payload and surface a warning on plan.
## Examples
- Native deployment + outcome rubric: [`examples/claude/deployment/`](../../examples/claude/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/)
+- Native deployment + file resources: [`examples/bailian/deployment/`](../../examples/bailian/deployment/)
+- Emulated deployment + file resources: [`examples/ark/deployment/`](../../examples/ark/deployment/)
diff --git a/docs/reference/cli.md b/docs/reference/cli.md
index cfee071..aa7e803 100644
--- a/docs/reference/cli.md
+++ b/docs/reference/cli.md
@@ -124,7 +124,7 @@ Manage scheduled / triggered deployments.
| `deployment get ` | Show a deployment's status and resolved bindings. |
| `deployment pause ` | Pause scheduled runs for a native deployment. |
| `deployment unpause ` | Resume a paused native deployment. |
-| `deployment run ` | Trigger a deployment run (native on Qoder/Claude, emulated as a session on Bailian/Volcengine Ark). |
+| `deployment run ` | Trigger a deployment run (native on Bailian/Qoder/Claude, emulated as a session on Volcengine Ark). |
## `agents memory-store`
diff --git a/docs/reference/providers.md b/docs/reference/providers.md
index 45380d1..2cdd665 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 | 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. |
-| Deployment | emulated | native | native | emulated | Qoder and Claude use native deployments; Bailian and Ark expand a deployment into a session at `run` time. |
+| 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. |
- **native** — the provider supports the feature directly.
@@ -32,7 +32,7 @@ The resource matrix above answers whether a declaration can be applied. The tabl
|------------------|:-------:|:-----:|:------:|:--------------:|----------------------|
| List agents, environments, and vaults | yes | yes | yes | yes | Powers resource discovery in the Web UI. |
| Export resources to YAML (`sync`) | yes | yes | yes | limited | Ark cannot enumerate skills, so skill export is skipped. |
-| Full drift comparison | Environment, Agent | Environment, Agent | no | no | Other supported resources degrade to existence checks; emulated deployments are local state. |
+| Full drift comparison | Environment, Agent | Environment, Agent | no | no | Other supported resources degrade to existence checks; deployment content is never compared. |
| List uploaded files | yes | yes | yes | yes | File upload, metadata lookup, and deletion are also implemented by all adapters. |
| Resolve artifact download URL | no | yes | no | no | Qoder exposes a short-lived file content URL. |
| List skills | yes | yes | yes | no | Ark supports lookup by ID, but its adapter cannot enumerate skills. |
@@ -46,7 +46,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.
+- **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.
- **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 0220771..0f43787 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 | native | native | Qoder、Claude(beta)、方舟均已接入 |
| Multi-Agent | unsupported | unsupported | native | native | Claude 与 火山方舟 支持 coordinator |
-| Deployment | emulated | native | native | emulated | Qoder 和 Claude 使用原生 Deployment;百炼和火山方舟在 `run` 时展开为 Session |
+| Deployment | native | native | native | emulated | 百炼、Qoder 和 Claude 使用原生 Deployment;火山方舟在 `run` 时展开为 Session |
| Session | native | native | native | native | 四者均原生支持 |
### Adapter 实现能力对照表
@@ -45,7 +45,7 @@ OpenAgentPack 通过 Provider 适配器与不同的 AI Agent 平台交互。每
|----------------|:----:|:-----:|:------:|:--------:|----------|
| 枚举 Agent、Environment、Vault | yes | yes | yes | yes | 用于 Web UI 的云端资源发现 |
| 导出资源到 YAML(`sync`) | yes | yes | yes | limited | 方舟无法枚举 Skill,因此会跳过 Skill 导出 |
-| 完整 Drift 内容比较 | Environment、Agent | Environment、Agent | no | no | 其他已支持资源降级为存在性检查;模拟 Deployment 仅有本地状态 |
+| 完整 Drift 内容比较 | Environment、Agent | Environment、Agent | no | no | 其他已支持资源降级为存在性检查;Deployment 不比较内容 |
| 枚举已上传文件 | yes | yes | yes | yes | 四个 Adapter 也都实现上传、元数据查询和删除 |
| 获取产物下载 URL | no | yes | no | no | Qoder 可返回短期有效的文件内容 URL |
| 枚举 Skill | yes | yes | yes | no | 方舟可按 ID 查询,但当前无法枚举 |
@@ -59,7 +59,7 @@ OpenAgentPack 通过 Provider 适配器与不同的 AI Agent 平台交互。每
#### Provider 特有实现与限制
-- **百炼**:Skill 通过 Files API 上传并支持扫描状态轮询;Agent 更新会生成平台侧版本;官方 MCP Server 按名称引用。
+- **百炼**:Skill 通过 Files API 上传并支持扫描状态轮询;Agent 更新会生成平台侧版本;官方 MCP Server 按名称引用;Deployment 为原生资源,支持服务端 cron 调度、手动触发和暂停/恢复。
- **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 | existence | existence | 可发现资源缺失 |
-| Deployment | unsupported | native | native 路径待验证 | unsupported | 百炼和火山方舟的 emulated Deployment 为本地记录 |
+| Deployment | unsupported | native | native 路径待验证 | unsupported | 百炼 Deployment 为原生资源但不比较内容;火山方舟的 emulated Deployment 仅为本地记录 |
Claude 的 drift detection 接口路径已预留;本仓库中的 live baseline 因 Anthropic API 账号余额不足未完成 Agent 创建验证。
@@ -139,7 +139,7 @@ qoder.multiagent.unsupported:
### 模拟(emulated)资源的能力降级
-`emulated` 等级表示 Provider 没有对应的原生原语,OpenAgentPack 通过其他原语间接实现。Deployment 在百炼和火山方舟上为模拟实现:`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` 时尽力降级:
@@ -153,13 +153,30 @@ qoder.multiagent.unsupported:
示例诊断输出:
```
-⚠ bailian.deployment.schedule_unsupported
- Resource: deployment.daily-report (bailian)
+⚠ ark.deployment.schedule_unsupported
+ Resource: deployment.daily-report (ark)
Schedules are not enforced server-side on this provider; trigger runs via external cron/CI.
+⚠ ark.deployment.define_outcome_unsupported
+ Resource: deployment.daily-report (ark)
+ Outcome rubrics (user.define_outcome) are not enforced server-side on this provider; the run executes without rubric grading.
+```
+
+### 原生 Deployment 的 payload 裁剪
+
+百炼 Deployment 是原生资源,但其 payload 比 OpenAgentPack 的中立声明更窄:`initial_events` 只承载消息,`resources` 只接受文件。被丢弃的字段会在 `plan`/`apply` 阶段输出**警告**:
+
+| 子特性 | 百炼行为 | 替代建议 |
+|--------|----------|---------|
+| `initial_events` 中的 `user.define_outcome` | 从 payload 中丢弃,不做结果评分 | 将要求写入 `user.message` / `system.message` |
+| `resources` 中的 `github_repository` | 从 payload 中丢弃 | 在 Session 内克隆仓库 |
+
+示例诊断输出:
+
+```
⚠ 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.
+ Outcome rubrics (user.define_outcome) are dropped from the Bailian deployment payload; the run executes without rubric grading.
```
## Provider 配置
diff --git a/examples/README.md b/examples/README.md
index 847c24c..5691413 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -65,7 +65,7 @@ extensions, and live-test commands.
| 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. |
-| Deployment | emulated | native | native | emulated | Qoder and Claude schedule server-side; Bailian and Ark expand into a session at `run` time. |
+| 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/deployment/agents.yaml b/examples/bailian/deployment/agents.yaml
index 0d9b847..e2c9622 100644
--- a/examples/bailian/deployment/agents.yaml
+++ b/examples/bailian/deployment/agents.yaml
@@ -26,30 +26,33 @@ agents:
tools:
builtin: [read, glob, grep, web_search]
-# Deployment is EMULATED on Bailian (no native deployment primitive):
-# - `agents apply` records local state only (remote_id = null)
-# - `agents deployment run daily-report` expands it into a Session, uploads the
-# file resource, and replays the initial_events.
-# `schedule` and `define_outcome` are NOT enforced server-side and will surface
-# warning diagnostics on plan. Use external cron/CI for scheduled runs.
+# Deployment is NATIVE on Bailian (real /deployments API):
+# - `agents apply` creates the remote Deployment (state records its remote_id)
+# - `schedule` runs server-side (cron + timezone)
+# - `agents deployment run daily-report` triggers a server-side run
+# - `agents deployment pause/unpause daily-report` toggles scheduled runs
+# `define_outcome` events and `github_repository` resources are NOT part of the
+# deployment payload and surface warning diagnostics on plan; only file resources
+# are uploaded (at apply time) and mounted into each run's Session.
deployments:
daily-report:
agent: reporter
- description: "Daily report (emulated -> Session on run)"
+ description: "Daily report (native Bailian deployment)"
schedule:
expression: "0 9 * * *"
timezone: UTC
initial_events:
- type: user.message
content: "Summarize yesterday's commits and generate the daily report."
- # define_outcome is accepted in config but filtered out on Bailian (no
- # server-side outcome evaluation); kept here for cross-provider parity.
+ # define_outcome is accepted in config but dropped from the Bailian
+ # deployment payload (no server-side outcome evaluation); kept here for
+ # cross-provider parity.
- type: user.define_outcome
description: "Daily report quality gate"
rubric: "Must include an executive summary and at least three key metrics."
max_iterations: 3
resources:
- # Local file uploaded at `deployment run` time and mounted into the Session.
+ # Local file uploaded at `apply` time and mounted into each run's Session.
- type: file
source: ./data/report-template.md
- mount_path: /data/report-template.md
+ mount_path: /mnt/report-template.md
diff --git a/examples/bailian/with-mcp/agents.yaml b/examples/bailian/with-mcp/agents.yaml
index f9fcb45..2f40d15 100644
--- a/examples/bailian/with-mcp/agents.yaml
+++ b/examples/bailian/with-mcp/agents.yaml
@@ -43,7 +43,7 @@ agents:
deployments:
web-search-demo:
agent: researcher
- description: "WebSearch demo (emulated -> Session on run)"
+ description: "WebSearch demo (native Bailian deployment)"
initial_events:
- type: user.message
content: |
diff --git a/package.json b/package.json
index ae70704..415e25c 100644
--- a/package.json
+++ b/package.json
@@ -76,7 +76,7 @@
"esbuild": "0.28.1",
"fast-equals": "5.3.3",
"js-yaml": "4.3.1",
- "nanoid": "3.3.17",
+ "nanoid": "3.3.18",
"postcss": "8.5.23"
},
"trustedDependencies": [
diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts
index c42abe9..551d7d3 100644
--- a/packages/cli/src/program.ts
+++ b/packages/cli/src/program.ts
@@ -328,7 +328,7 @@ deploymentCmd
deploymentCmd
.command("run ")
- .description("Trigger a deployment run (native on Qoder/Claude, emulated on Bailian/Ark)")
+ .description("Trigger a deployment run (native on Bailian/Qoder/Claude, emulated on Ark)")
.addOption(configFileOption())
.addOption(providerOption("Target provider"))
.action(withResolvedConfigFile(deploymentRunCommand));
diff --git a/packages/sdk/src/internal/core/validate-config.ts b/packages/sdk/src/internal/core/validate-config.ts
index dcac16f..3a6a862 100644
--- a/packages/sdk/src/internal/core/validate-config.ts
+++ b/packages/sdk/src/internal/core/validate-config.ts
@@ -9,7 +9,7 @@ import { getProvider } from "../providers/registry.ts";
import type { ProjectConfig } from "../types/config.ts";
import type { Diagnostic } from "../types/plan.ts";
import type { ResourceAddress } from "../types/state.ts";
-import { providerMountPrefix } from "../utils/sandbox-mount.ts";
+import { providerMountPrefix, resolveSandboxMountPath } from "../utils/sandbox-mount.ts";
import { findMissingBailianMcpToolConfigs } from "../validation/bailian.ts";
export interface ValidateProjectConfigOptions {
@@ -364,6 +364,83 @@ export function collectProviderCapabilities(
}
}
+ if (providerName === "bailian") {
+ // Bailian deployments are native, but their payload is narrower than the
+ // provider-neutral declaration: initial_events carry messages only, and
+ // resources accept files only. Surface what gets dropped.
+ for (const [name, deployment] of Object.entries(config.deployments ?? {})) {
+ if (deployment.provider && deployment.provider !== providerName) continue;
+ const addr: ResourceAddress = {
+ type: "deployment",
+ name,
+ provider: providerName,
+ };
+
+ if (deployment.initial_events?.some((event) => event.type === "user.define_outcome")) {
+ diagnostics.warning(
+ `${providerName}.deployment.define_outcome_unsupported`,
+ "Outcome rubrics (user.define_outcome) are dropped from the Bailian deployment payload; the run executes without rubric grading.",
+ addr,
+ );
+ }
+ if (
+ !deployment.initial_events?.some((event) => event.type === "user.message" || event.type === "system.message")
+ ) {
+ diagnostics.error(
+ `${providerName}.deployment.initial_events.message_required`,
+ `deployment.${name}: Bailian requires at least one user.message or system.message initial event; user.define_outcome events are dropped.`,
+ addr,
+ );
+ }
+
+ if (deployment.resources?.some((resource) => resource.type === "github_repository")) {
+ diagnostics.warning(
+ `${providerName}.deployment.github_repository_unsupported`,
+ "Bailian deployment resources accept files only; github_repository resources are dropped. Clone the repository inside the session instead.",
+ addr,
+ );
+ }
+
+ const mountPrefix = providerMountPrefix(providerName);
+ const normalizedMountPaths = new Set();
+ for (const resource of deployment.resources ?? []) {
+ if (resource.type !== "file") continue;
+ if (!resource.mount_path?.trim()) {
+ diagnostics.error(
+ `${providerName}.deployment.file.mount_path.required`,
+ `deployment.${name}: Bailian file resources require mount_path.`,
+ addr,
+ );
+ continue;
+ }
+ if (
+ mountPrefix &&
+ resource.mount_path.startsWith("/") &&
+ resource.mount_path !== mountPrefix &&
+ !resource.mount_path.startsWith(`${mountPrefix}/`)
+ ) {
+ diagnostics.error(
+ `${providerName}.deployment.file.mount_path.invalid`,
+ `deployment.${name}: Bailian file mount_path must start with '${mountPrefix}/'.`,
+ addr,
+ );
+ continue;
+ }
+
+ const normalizedMountPath = resolveSandboxMountPath(providerName, resource.mount_path);
+ if (normalizedMountPaths.has(normalizedMountPath)) {
+ diagnostics.error(
+ `${providerName}.deployment.file.mount_path.duplicate`,
+ `deployment.${name}: Bailian file mount_path '${normalizedMountPath}' is duplicated after normalization.`,
+ addr,
+ );
+ } else {
+ normalizedMountPaths.add(normalizedMountPath);
+ }
+ }
+ }
+ }
+
if (providerName !== "qoder") {
for (const [name, env] of Object.entries(config.environments ?? {})) {
// External references are never sent to the provider API, so a
@@ -451,7 +528,11 @@ export function collectProviderCapabilities(
if (config.deployments && caps.deployment.tier === "emulated") {
for (const [name, dep] of Object.entries(config.deployments)) {
if (dep.provider && dep.provider !== providerName) continue;
- const addr: ResourceAddress = { type: "deployment", name, provider: providerName };
+ const addr: ResourceAddress = {
+ type: "deployment",
+ name,
+ provider: providerName,
+ };
if (dep.schedule) {
diagnostics.warning(
diff --git a/packages/sdk/src/internal/executor/executor.ts b/packages/sdk/src/internal/executor/executor.ts
index 208c76a..9794754 100644
--- a/packages/sdk/src/internal/executor/executor.ts
+++ b/packages/sdk/src/internal/executor/executor.ts
@@ -5,6 +5,7 @@ import { getResourceDeclaration } from "../planner/declaration.ts";
import { computeReplacementFingerprint, computeResourceHash } from "../planner/hasher.ts";
import { buildReadinessBaseline } from "../planner/plan-semantics.ts";
import { ApiError, ConflictError } from "../providers/base-client.ts";
+import { DeploymentCreateConflictError } from "../providers/deployment-conflict.ts";
import { readComparableIfSupported } from "../providers/drift-support.ts";
import type { RemoteResource } from "../providers/interface.ts";
import type { DriftReadAdapter, ResourceCrudAdapter } from "../providers/resource-workflow.ts";
@@ -597,16 +598,41 @@ async function executeActionInner(
case "deployment": {
const decl = ctx.config.deployments![name]!;
const refs = resolveDeploymentRefs(name, ctx.config, address.provider, ctx.state);
- if (isUpdate) {
- result = await provider.updateDeployment(existingId!, name, decl, refs, ctx.configPath ?? "");
- } else {
+ const hasLocalFileSources = decl.resources?.some(
+ (resource) => resource.type === "file" && !resource.file_id && Boolean(resource.source),
+ );
+ const materializeDeployment = async (): Promise => {
try {
- result = await provider.createDeployment(name, decl, refs, ctx.configPath ?? "");
+ return await provider.createDeployment(name, decl, refs, ctx.configPath ?? "");
} catch (err) {
- result = await adoptOnConflict(err, address, provider, ctx.onFeedback, {
- onExisting: (existing) => provider.updateDeployment(existing.id!, name, decl, refs, ctx.configPath ?? ""),
+ const preparedFiles = err instanceof DeploymentCreateConflictError ? err.preparedFiles : undefined;
+ const existing = await adoptOnConflict(err, address, provider, ctx.onFeedback, {
+ onExisting: (existing) =>
+ provider.updateDeployment(existing.id!, name, decl, refs, ctx.configPath ?? "", preparedFiles),
+ });
+ adopted = true;
+ return existing;
+ }
+ };
+ if (isUpdate && existingId) {
+ result = await provider.updateDeployment(existingId, name, decl, refs, ctx.configPath ?? "");
+ } else {
+ // A deployment with local file sources uploads before its create request. If the
+ // remote deployment already exists, an optimistic create would upload once,
+ // conflict, then upload again during adoption. Preflight this side-effecting
+ // path so the existing deployment is updated with a single set of uploads.
+ const existing = hasLocalFileSources ? await findExistingByNames(provider, "deployment", [name]) : null;
+ if (existing) {
+ result = await provider.updateDeployment(existing.resource.id!, name, decl, refs, ctx.configPath ?? "");
+ emitRuntimeFeedback(ctx.onFeedback, {
+ type: "resource_adopted",
+ level: "info",
+ resource: address,
+ message: `adopt deployment.${name} (${address.provider}) — already existed remotely as "${existing.name}"`,
});
adopted = true;
+ } else {
+ result = await materializeDeployment();
}
}
break;
diff --git a/packages/sdk/src/internal/planner/hasher.ts b/packages/sdk/src/internal/planner/hasher.ts
index c9f107a..e7980aa 100644
--- a/packages/sdk/src/internal/planner/hasher.ts
+++ b/packages/sdk/src/internal/planner/hasher.ts
@@ -28,9 +28,16 @@ export async function computeResourceHash(
}
}
+ if (address.type === "file" && basePath) {
+ const fileDecl = decl as { source: string };
+ const fileHash = computeLocalFileContentHash(fileDecl.source, basePath);
+ return contentHash({ decl, fileHash });
+ }
+
if (address.type === "deployment") {
const refs = resolveDeploymentReferenceIds(decl as DeploymentRefDecl, config, address.provider, state);
- if (refs) return contentHash({ decl, refs });
+ const sourceHashes = basePath ? computeDeploymentSourceHashes(decl as DeploymentRefDecl, basePath) : undefined;
+ if (refs || sourceHashes) return contentHash({ decl, refs, sourceHashes });
}
if (address.type === "template") {
@@ -77,6 +84,7 @@ function resolveChannelReferenceIds(
interface DeploymentRefDecl {
agent: string;
environment?: string;
+ resources?: Array<{ type: string; file_id?: string; source?: string }>;
}
interface TemplateRefDecl {
@@ -150,6 +158,26 @@ function getDeclaration(address: ResourceAddress, config: ProjectConfig): unknow
return getResourceDeclaration(address, config);
}
+function computeDeploymentSourceHashes(decl: DeploymentRefDecl, basePath: string): Record | undefined {
+ const sources = [
+ ...new Set(
+ (decl.resources ?? []).flatMap((resource) =>
+ resource.type === "file" && !resource.file_id && resource.source ? [resource.source] : [],
+ ),
+ ),
+ ];
+ if (sources.length === 0) return undefined;
+
+ return Object.fromEntries(sources.map((source) => [source, computeLocalFileContentHash(source, basePath)]));
+}
+
+export function computeLocalFileContentHash(source: string, basePath: string): string {
+ const fullPath = resolve(dirname(basePath), source);
+ const stat = statSync(fullPath, { throwIfNoEntry: false });
+ if (!stat?.isFile()) return "";
+ return contentHash(readFileSync(fullPath).toString("base64"));
+}
+
export function computeSkillContentHash(source: string, basePath: string): string {
const fullPath = resolve(dirname(basePath), source);
const stat = statSync(fullPath, { throwIfNoEntry: false });
diff --git a/packages/sdk/src/internal/planner/planner.ts b/packages/sdk/src/internal/planner/planner.ts
index 4fa4e3d..999b972 100644
--- a/packages/sdk/src/internal/planner/planner.ts
+++ b/packages/sdk/src/internal/planner/planner.ts
@@ -5,6 +5,7 @@ import {
} from "../core/validate-config.ts";
import { DiagnosticCollector } from "../diagnostics/diagnostics.ts";
import { buildDependencyGraph, type DependencyGraph, topologicalSort } from "../graph/dependency.ts";
+import { getProvider } from "../providers/registry.ts";
import type { ProjectConfig } from "../types/config.ts";
import type { ExecutionPlan, PlannedAction } from "../types/plan.ts";
import type { ResourceAddress, StateFile } from "../types/state.ts";
@@ -53,6 +54,10 @@ export async function buildPlan(
const desiredHash = await computeResourceHash(address, config, options.configPath, hashStateLookup);
const existing = stateIndex.get(key);
const deps = getDependencies(address, graph);
+ const needsNativeDeploymentMaterialization =
+ address.type === "deployment" &&
+ existing?.remote_id === null &&
+ getProvider(address.provider)?.capabilities.deployment.tier === "native";
if (address.type === "environment" && existing) {
const envDecl = config.environments?.[address.name];
@@ -134,6 +139,17 @@ export async function buildPlan(
after: { content_hash: desiredHash },
dependencies: deps,
});
+ } else if (needsNativeDeploymentMaterialization) {
+ actions.push({
+ action: "update",
+ address,
+ driftKind: "none",
+ readinessImpact: "blocking",
+ reason: "Materialize legacy state as a native deployment",
+ before: { content_hash: existing.desired_hash ?? existing.content_hash },
+ after: { content_hash: desiredHash },
+ dependencies: deps,
+ });
} else if (
(existing.desired_hash ?? existing.content_hash) !== desiredHash &&
existing.drift_status === "drifted"
diff --git a/packages/sdk/src/internal/providers/bailian/adapter.ts b/packages/sdk/src/internal/providers/bailian/adapter.ts
index c0e17da..5d779dd 100644
--- a/packages/sdk/src/internal/providers/bailian/adapter.ts
+++ b/packages/sdk/src/internal/providers/bailian/adapter.ts
@@ -34,10 +34,13 @@ import type { ProviderSkillInfo } from "../../types/skill-info.ts";
import type { ResourceType } from "../../types/state.ts";
import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts";
import { toRemoteResource } from "../base-client.ts";
+import { preserveDeploymentFilesOnConflict } from "../deployment-conflict.ts";
import type {
ComparableRemoteResource,
DeploymentContext,
DeploymentInfo,
+ DeploymentListFilter,
+ DeploymentListResult,
DeploymentRunResult,
DriftSupport,
ExportedResource,
@@ -63,9 +66,9 @@ import {
fileToDecl,
mapAgent,
mapCredential,
- mapDeploymentToSession,
+ mapDeployment,
+ mapDeploymentUpdate,
mapEnvironment,
- mapInitialEvents,
mapSendMessage,
mapSession,
mapVault,
@@ -95,6 +98,7 @@ export class BailianAdapter implements ProviderAdapter {
skill: "/skills",
vault: "/vaults",
file: "/files",
+ deployment: "/deployments",
};
async findResource(type: ResourceType, name: string, id?: string | null): Promise {
@@ -549,76 +553,130 @@ export class BailianAdapter implements ProviderAdapter {
await this.client.delete(`/vaults/${vaultId}/credentials/${credentialId}`);
}
- // --- Deployment (emulated) ---
+ // --- Deployment ---
async createDeployment(
- _name: string,
- _decl: DeploymentDecl,
- _refs: ResolvedDeploymentRefs,
- _basePath: 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);
+ try {
+ const res = (await this.client.post("/deployments", body)) as Record;
+ return toRemoteResource(res);
+ } catch (error) {
+ preserveDeploymentFilesOnConflict(error, uploaded);
+ }
}
async updateDeployment(
- _id: string,
- _name: string,
- _decl: DeploymentDecl,
- _refs: ResolvedDeploymentRefs,
- _basePath: string,
+ id: string,
+ name: string,
+ decl: DeploymentDecl,
+ refs: ResolvedDeploymentRefs,
+ basePath: string,
+ preparedFiles?: ReadonlyMap,
): Promise {
- return { id: null, type: "deployment" };
+ // Deployments used to be emulated here, so state rows written before native
+ // support carry `remote_id: null`. An update against one has nothing to PATCH —
+ // materialize it remotely instead of failing on an empty path segment.
+ if (!id) return this.createDeployment(name, decl, refs, basePath);
+
+ const current = (await this.client.get(`/deployments/${id}`)) as Record;
+ const uploaded = preparedFiles ? new Map(preparedFiles) : await this.uploadDeploymentFiles(decl, basePath);
+ const body = mapDeploymentUpdate(
+ name,
+ decl,
+ refs,
+ this.projectName,
+ uploaded,
+ current.metadata as Record | undefined,
+ );
+ 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 {
- 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));
- }
- }
+ if (!ctx.id) {
+ throw new UserError(`Deployment '${ctx.name}' has no remote id; run \`agents apply\` first.`);
}
+ 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,
+ };
+ }
- const body = mapDeploymentToSession(ctx.decl, ctx.refs, fileIds);
- const sessionRes = (await this.client.post("/sessions", body)) as Record;
- const sessionId = sessionRes.id as string;
-
- const eventsBody = mapInitialEvents(ctx.decl.initial_events);
- const input = (eventsBody as { input: unknown[] }).input;
- if (input.length) {
- await this.client.post(`/sessions/${sessionId}/events`, eventsBody);
+ async getDeployment(ctx: DeploymentContext): Promise {
+ 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.get(`/deployments/${ctx.id}`)) as Record;
+ return toDeploymentInfo(res);
}
- async getDeployment(ctx: DeploymentContext): Promise {
- const plan = mapDeploymentToSession(ctx.decl, ctx.refs, []);
+ async listDeployments(filter?: DeploymentListFilter): Promise {
+ const params = new URLSearchParams();
+ if (filter?.agent_id) params.set("agent_id", filter.agent_id);
+ if (filter?.status) params.set("status", filter.status);
+ if (filter?.include_archived) params.set("include_archived", "true");
+ if (filter?.limit) params.set("limit", String(filter.limit));
+ if (filter?.page) params.set("page", filter.page);
+ if (filter?.created_at_gte) params.set("created_at[gte]", filter.created_at_gte);
+ if (filter?.created_at_lte) params.set("created_at[lte]", filter.created_at_lte);
+ const query = params.toString();
+ const res = (await this.client.get(`/deployments${query ? `?${query}` : ""}`)) as Record;
+ // The list response carries no `has_more`; a non-null `next_page` cursor is the signal.
+ const nextPage = (res.next_page as string | null | undefined) ?? undefined;
return {
- id: ctx.id,
- status: "emulated (local)",
- schedule: ctx.decl.schedule,
- attributes: { materialization_plan: plan },
+ deployments: ((res.data as Record[] | undefined) ?? []).map(toDeploymentInfo),
+ has_more: nextPage != null,
+ next_page: nextPage,
};
}
- private async uploadSessionFile(source: string, basePath: string): Promise {
+ async pauseDeployment(ctx: DeploymentContext): Promise {
+ return this.setDeploymentPaused(ctx, true);
+ }
+
+ async unpauseDeployment(ctx: DeploymentContext): Promise {
+ return this.setDeploymentPaused(ctx, false);
+ }
+
+ private async setDeploymentPaused(ctx: DeploymentContext, paused: boolean): Promise {
+ if (!ctx.id) throw new UserError(`Deployment '${ctx.name}' has no remote id; run \`agents apply\` first.`);
+ const action = paused ? "pause" : "unpause";
+ const res = (await this.client.post(`/deployments/${ctx.id}/${action}`, {})) as Record;
+ return toDeploymentInfo(res);
+ }
+
+ private async uploadDeploymentFiles(decl: DeploymentDecl, basePath: string): Promise