Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions docs/guides/configure-an-agent.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,77 @@ agents:

`memory_stores` 字段接收存储名称数组。

#### 配置 Qoder Forward 默认 Memory Store

Qoder Forward 本地声明的 Environment 只通过 Forward Environment API 创建。外部 `environment_id`
既可以引用 Managed API 的 Environment,也可以引用 Forward API 的 Environment;OpenCMA 不会创建或
修改此类外部引用,并会在检查其是否存在时自动解析 API 域。Forward Agent 引用的自定义 Skill、Vault/Credential、File 和显式 Memory
Store 均通过 Forward API 管理;本地管理的 Environment、Skill、Vault、File 或 Memory Store 不能同时
供 Managed 与 Forward Agent 共用,应分别声明。Forward File 通过 Agent 的 `files` 字段绑定到 Template。
显式 Memory Store 依赖 `defaults.identity`,并以只读方式挂载到对应的 Identity + Template。

Qoder Forward 会在某个 `(Identity, Template)` 首次创建 Session 时自动建立唯一可写的系统默认
Memory Store。`default_memory_store` 管理的是这个由 Qoder 创建的 Store,而不是在顶层
`memory_stores` 中额外创建一个普通 Store。可以为它设置有业务含义的展示名称、描述以及销毁策略:

```yaml
defaults:
provider: qoder
identity: support-user

identities:
support-user:
external_id: support-user # 由 OpenCMA 管理;不是 identity_id

agents:
support-agent:
# ...其他配置
delivery:
qoder:
type: forward
default_memory_store:
name: "客服群长期记忆"
description: "群聊中确认过的业务知识和处理规则"
delete_on_destroy: false
```

##### apply 行为

- 仅支持 Qoder Forward,并且必须配置 `defaults.identity`。
- `agents apply` 会通过 `defaults.identity` 和当前 Template 定位 `system_managed=true`、
`access=read_write` 的 Store,然后幂等更新 `name` 和可选的 `description`。
- `name` 会成为云端 Store 的展示名称,因此可以将系统生成的默认名称改为有业务含义的名字。
- OpenCMA 不会为了生成 Store 而创建额外的初始化 Session。首次真实 Session 尚未创建时,apply 会提示
pending;Session 创建后再次执行 apply 即可完成名称和描述的收敛。

##### destroy 行为

`delete_on_destroy` 控制执行 `agents destroy` 时是否永久删除这个系统默认 Store:

| 配置 | destroy 结果 |
|---|---|
| 未配置或 `false` | 保留 Store、全部 Memory 和版本历史。这是默认行为。 |
| `true` | 系统挂载解除后,永久删除 Store、全部 Memory 和版本历史。 |

永久删除的执行顺序如下:

1. 删除任何项目资源前,先用 Identity ID 和 Template ID 查询并保存默认 Store ID。
2. archive Template,并删除由 OpenCMA 管理的 Identity,以解除系统挂载。
3. 使用之前保存的 Store ID 永久删除默认 Store。Qoder 可能异步解除系统挂载,因此遇到
`still mounted` 时会进行有界退避重试。
4. 如果挂载冲突仍未解除,尝试 `archive → delete`。真机 Qoder 已验证该路径可以完成永久删除。

如果预检阶段无法解析 Identity、Template,或无法完成 Store 查询,destroy 会在删除任何项目资源之前
整体中止。如果最后的 Store 删除仍失败,命令会返回 `partial`,并把待清理 Store ID 保存在 state 中,
不会把结果显示成完整成功。即使其他资源已经全部删除,之后再次执行 `agents destroy` 也会继续清理。
Store 已不存在时按 `already absent` 处理。

永久删除要求 `defaults.identity` 指向由 OpenCMA 管理的 Identity。外部 `identity_id` 永远不会被
OpenCMA 删除,会继续挂载默认 Store,因此 `delete_on_destroy: true` 会直接校验失败。

> **警告:** `true` 会不可恢复地删除 Store 内容和全部版本历史。除非明确需要清除数据,否则应保持默认
> `false`。`--cascade` 不会覆盖 `delete_on_destroy`。

---

## 多 Agent 协作
Expand Down
61 changes: 61 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,13 +280,62 @@ agents:
skills: [ <string> | { type, skill_id, version? } ]
vault: <string>
memory_stores: [ <string> ]
default_memory_store: # Qoder Forward only; requires defaults.identity
name: <string> # 1-255 characters
description: <string> # optional; up to 1024 characters
delete_on_destroy: <boolean> # optional; defaults to false (retain)
environment_variables: { <key>: <string> } # Qoder only
managed_tool_config: { enabled_tools: [ <string> ] } # Qoder Forward delivery only
resources: [ SessionResource ]
multiagent: { type: "coordinator", agents: [...] }
metadata: { <key>: <string> }
```

### Qoder Forward default Memory Store

Qoder creates one writable, system-managed Memory Store for an `(Identity, Template)` pair when its first Forward Session is created. `default_memory_store` lets OpenCMA manage the display metadata and destroy policy of that provider-created Store; it does not declare a second, ordinary entry under the top-level `memory_stores` collection.

```yaml
defaults:
provider: qoder
identity: support-user

identities:
support-user:
external_id: support-user # managed by OpenCMA

agents:
support:
# ...
delivery:
qoder:
type: forward
default_memory_store:
name: "Support group memory"
description: "Confirmed support knowledge and operating rules"
delete_on_destroy: false
```

Apply behavior:

- Requires Qoder Forward delivery and `defaults.identity`.
- Locates the Store mounted as `system_managed: true` and `access: read_write`, then idempotently updates its `name` and optional `description`.
- Does not create an initialization Session. Before the first real Session has created the Store, apply reports the reconciliation as pending. Run apply again after a Session exists.
- `name` changes the provider Store's display name, so it can be meaningful instead of remaining the provider-generated default.

Destroy behavior:

| `delete_on_destroy` | Result |
|---|---|
| omitted or `false` | Retain the Store, its Memories, and all version history. This is the default. |
| `true` | Permanently delete the Store, its Memories, and all version history after its system mount has been removed. |

For permanent deletion, OpenCMA captures and persists the Store ID before archiving the Template and deleting the Identity. Qoder may remove the system mount asynchronously, so OpenCMA uses bounded retries for a `still mounted` conflict. If the conflict remains, it tries `archive → delete`, matching the lifecycle verified against the live Qoder service. A cleanup that still cannot finish is retained in state and reported as a partial destroy; a later `agents destroy` resumes it even when all ordinary resources are already gone.

If the preflight cannot resolve the Identity, Template, or Store lookup, destroy aborts before deleting any project resource. Authentication, permission, and non-retryable validation errors fail immediately. `--cascade` does not override this field.

`delete_on_destroy: true` requires an OpenCMA-managed Identity. An external `identity_id` is never deleted by OpenCMA, so it keeps the system Store mounted and fails configuration validation. Permanent deletion is irreversible; keep the default `false` unless data removal is explicitly required.

| Field | Type | Required | Description |
|-------|------|:--------:|-------------|
| `model` | string \| map<provider,string> | yes | Single model or a per-provider map. |
Expand All @@ -301,14 +350,26 @@ agents:
| `mcp_servers[]` | `{ name, type?, url? }` | no | URL (`url`/`http`) or `official` MCP server. |
| `skills[]` | string \| AgentSkillRef | no | Skill name or `{ type: "official"\|"custom", skill_id, version? }`. |
| `vault` | string | no | Vault name. |
| `files` | string[] | no | File declarations inherited by a Qoder Forward Template. These files are created through the Forward File API. |
| `memory_stores` | string[] | no | Bound memory stores. |
| `default_memory_store.name` | string | yes (with `default_memory_store`) | Display name for Qoder Forward's writable system-managed Store; 1–255 characters. |
| `default_memory_store.description` | string | no | Display description for the system-managed Store; up to 1024 characters. |
| `default_memory_store.delete_on_destroy` | boolean | no | Permanently delete the Store during destroy. Defaults to `false` (retain). |
| `environment_variables` | map<string,string> | 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. |
| `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. |
| `metadata` | map<string,string> | no | Free-form metadata. |

For Qoder Forward delivery, a locally declared Environment is created only through the Forward Environment API. An
external `environment_id` may reference an Environment from either the Managed API or the Forward API; OpenCMA does
not create or mutate such a reference and resolves its API domain when checking existence. Referenced custom Skills, Vaults and Credentials, Files, and explicit
Memory Stores are created through their Forward APIs. A locally managed Environment, Skill, Vault, File, or Memory
Store cannot be shared by Managed and Forward Agents under one logical declaration; declare separate resources for
the two API domains. Explicit Forward Memory Stores require `defaults.identity` and are mounted read-only to that
Identity and Template.

### Session resources

Qoder and Claude managed Sessions support a provider-neutral GitHub repository resource:
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/commands/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,20 @@ export async function applyCommand(options: {

const actionable = plan.actions.filter((a) => a.action !== "no-op");
if (actionable.length === 0) {
log.success("No changes. Infrastructure is up-to-date.");
if (options.refreshOnly) {
log.success("No changes. Infrastructure is up-to-date.");
return;
}
const s = p.spinner({ output: process.stderr });
s.start("Reconciling provider-managed defaults...");
const result = await executePlannedProject(planned, {
onFeedback: renderRuntimeFeedback,
policy: "force",
concurrency: options.concurrency,
});
s.stop("Provider-managed defaults reconciled.");
if (result.partial) throw new UserError("Apply failed.");
log.success("No resource changes. Infrastructure is up-to-date.");
return;
}

Expand Down
27 changes: 22 additions & 5 deletions packages/cli/src/commands/destroy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as p from "@clack/prompts";
import {
type DestroyDefaultMemoryStoreResult,
type DestroyResourceResult,
destroyPlannedProjectResources,
planDestroyProjectContext,
Expand All @@ -14,7 +15,8 @@ export async function destroyCommand(options: { file: string; yes?: boolean; cas
const planned = planDestroyProjectContext(ctx);
const resources = planned.resources;

if (resources.length === 0) {
const pendingDefaultMemoryStores = planned.defaultMemoryStores.filter((store) => store.memoryStoreId);
if (resources.length === 0 && pendingDefaultMemoryStores.length === 0) {
log.info("No resources in state. Nothing to destroy.");
return;
}
Expand All @@ -23,6 +25,13 @@ export async function destroyCommand(options: { file: string; yes?: boolean; cas
for (const r of resources) {
console.log(chalk.red(` - ${formatResourceLabel(r.address)} [${r.remote_id}]`));
}
for (const store of planned.defaultMemoryStores) {
const policy = store.deleteOnDestroy ? chalk.red.bold("permanently delete") : chalk.green("retain");
console.log(` - default_memory_store.${store.agentName} [${policy}]`);
}
if (planned.defaultMemoryStores.some((store) => store.deleteOnDestroy)) {
console.log(chalk.red.bold("\nDefault Memory Store content and all version history will be permanently deleted."));
}

if (!options.yes) {
const shouldDestroy = await p.confirm({
Expand Down Expand Up @@ -69,14 +78,22 @@ export async function destroyCommand(options: { file: string; yes?: boolean; cas
activeSpinner = undefined;
},
});
for (const store of result.defaultMemoryStoreResults) renderDefaultMemoryStoreResult(store);

const summary =
result.destroyed === result.resources.length
? chalk.green(`Destroy complete. ${result.destroyed}/${result.resources.length} resources removed.`)
: chalk.yellow(`Destroy complete. ${result.destroyed}/${result.resources.length} resources removed.`);
const summary = !result.partial
? chalk.green(`Destroy complete. ${result.destroyed}/${result.resources.length} resources removed.`)
: chalk.yellow(`Destroy partial. ${result.destroyed}/${result.resources.length} resources removed.`);
p.outro(summary, { output: process.stderr });
}

function renderDefaultMemoryStoreResult(result: DestroyDefaultMemoryStoreResult): void {
const label = `default_memory_store.${result.agentName}`;
if (result.status === "retained") log.success(`${label} — retained`);
else if (result.status === "deleted") log.success(`${label} — permanently deleted`);
else if (result.status === "already_gone") log.warn(`${label} — already absent`);
else log.error(`${label} — delete failed: ${result.error ?? "unknown error"}`);
}

function stopResourceSpinner(spinner: ReturnType<typeof p.spinner> | undefined, result: DestroyResourceResult): void {
const label = formatResourceLabel(result.resource.address);
if (!spinner) {
Expand Down
5 changes: 4 additions & 1 deletion packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,10 @@ export {

export type { DeploymentListFilter, DeploymentListResult } from "./internal/providers/interface.ts";

export type { DestroyResourceResult } from "./internal/core/destroy-runtime.ts";
export type {
DestroyDefaultMemoryStoreResult,
DestroyResourceResult,
} from "./internal/core/destroy-runtime.ts";
export {
destroyPlannedProjectResources,
planDestroyProjectContext,
Expand Down
Loading
Loading