diff --git a/apps/cli/config/agent-presets/core-web/agent.cordis.yml b/apps/cli/config/agent-presets/core-web/agent.cordis.yml new file mode 100644 index 0000000000..48f7b5b3a0 --- /dev/null +++ b/apps/cli/config/agent-presets/core-web/agent.cordis.yml @@ -0,0 +1,31 @@ +# The `core-web` agent preset: the two-tool benchmark surface. +# +# The native model surface is exactly persistent `bash` plus +# `str_replace_editor`. Everything else a session could reach — skills, goals, +# plan mode, delegation, workflows, todo, web — is simply absent rather than +# disabled, because a preset composes what an agent has instead of subtracting +# from a shared default. +# +# The host composition is unchanged: this agent still runs inside the same +# sandbox, approval, persistence, and model routing as any other session. + +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: >- + You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + +# `tool-bash` provides the `bashEnv` service, so it needs a realm even alone. +- id: shell + name: cordis:group + group: true + isolate: + bashEnv: true + config: + - id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +- id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml new file mode 100644 index 0000000000..ccf0d92360 --- /dev/null +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -0,0 +1,241 @@ +# The `standard` agent preset: the full coding agent, mounted per session. +# +# This file is an AGENT-PLANE composition. It is mounted under one agent's +# scope context, so every tool and prompt section it registers belongs to that +# session alone. The host composition (`base.cordis.yml` + `web.cordis.yml`) +# keeps everything a preset must not own: the registries themselves, the +# sandbox and approval stack, persistence, and the model route. +# +# A service row here MUST sit inside a group carrying an `isolate` realm. +# Without one it publishes into the root realm, where it is process-global +# rather than per-session and the second session mounting this preset collides +# with the first; `dsh-agent-presets` rejects that at mount. `true` means an +# entry-local realm — one private instance per mounted session, which is the +# default this deployment wants. A shared label would instead pool one instance +# across every session naming it. + +# ── identity ──────────────────────────────────────────────────────────────── + +# The preset's own persona, shadowing the deployment default for this agent. +# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace. +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: >- + You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + +- id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' + config: + maxBytes: 65536 + +# ── shell ─────────────────────────────────────────────────────────────────── + +# `tool-bash` reads as a tool but provides the `bashEnv` service, so it needs a +# realm like any other provider. The executor behind it (`bash-sandbox`) stays +# in the host composition, where the sandbox policy owns it. +- id: shell + name: cordis:group + group: true + isolate: + bashEnv: true + config: + - id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +# ── filesystem ────────────────────────────────────────────────────────────── + +# All three register into the host `tools` registry and provide nothing, so +# they need no realm. The `fs` service and its policy stay in the host. +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + +- id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + +# ── background tasks ──────────────────────────────────────────────────────── + +- id: tasks + name: cordis:group + group: true + isolate: + tasks: true + config: + - id: tasks-local + name: '@deepseek-ai/dsh-tasks-local' + + - id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +# ── skills ────────────────────────────────────────────────────────────────── + +- id: skills + name: cordis:group + group: true + isolate: + skills: true + config: + - id: skill + name: '@deepseek-ai/dsh-skill' + + - id: skill-local + name: '@deepseek-ai/dsh-skill-local' + + - id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +# ── goals ─────────────────────────────────────────────────────────────────── + +- id: goals + name: cordis:group + group: true + isolate: + goals: true + config: + - id: goal + name: '@deepseek-ai/dsh-goal' + + - id: goal-session + name: '@deepseek-ai/dsh-goal-session' + + - id: command-goal + name: '@deepseek-ai/dsh-command-goal' + + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── plan mode ─────────────────────────────────────────────────────────────── + +# Plan state is per-agent by nature, so an entry-local realm is not a +# workaround here — it is the correct lifetime. +- id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + +# ── compaction ────────────────────────────────────────────────────────────── + +# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must +# share this realm rather than sit outside it. +- id: compaction + name: cordis:group + group: true + isolate: + tokenMeter: true + compact: true + toolResultPrune: true + config: + - id: token-meter + name: '@deepseek-ai/dsh-token-meter' + + - id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + + - id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + +# ── delegation and workflows ──────────────────────────────────────────────── + +# Every backend and every tool that reaches `subagents` or `workflows` shares +# one realm: a consumer left outside it would resolve the host's registry +# instead, which this preset does not populate. +- id: delegation + name: cordis:group + group: true + isolate: + subagents: true + workflows: true + config: + - id: subagent + name: '@deepseek-ai/dsh-subagent' + + - id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + + - id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: continuable + + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + + - id: tool-subagent-report + name: '@deepseek-ai/dsh-tool-subagent-report' + + - id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 + +# ── remaining model-facing rows ───────────────────────────────────────────── + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + +# The `web` service and its search provider stay in the host composition; only +# the model-facing tool is per-session. +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: false + searchTimeoutMs: 60000 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0a70b0f69f..60924f99ab 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -81,7 +81,7 @@ async resolve(id?: string): Promise async mount(agentCtx: Context, id?: string): Promise ``` -Source: [`packages/preset/agent-presets/src/index.ts:36`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:37`](../../packages/preset/agent-presets/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -253,7 +253,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:242`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:243`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -1785,7 +1785,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [PrepareSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:800`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:803`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index 58321e0f22..f1e4fe5030 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/persistence.md -persistence.md: 0968496201defa869d94925e8e5ae3c5da1bbd37 -persistence.zh.md: efb01427b4355e531fb9b86922223cf27d3b3db0 +persistence.md: 65b000516894c6dfb4c661f0b9112197317197e6 +persistence.zh.md: 214b631063ae5368b95e2fa9603941eb7b3bbe64 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 0968496201..1b8f124661 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -77,12 +77,19 @@ interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number + /** + * Id of the agent preset this session's agent was composed from, when the + * deployment composes per session. Durable because the preset decides the + * session's tools and prompt: a resume that restored a different composition + * would replay history the model can no longer act on. + */ + readonly agentPreset?: string } ``` ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. +Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, the `agentPreset` the agent was composed from, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. ```ts type-equiv /** @@ -104,6 +111,7 @@ interface CreateSessionOptions { readonly seedLength?: number readonly origin?: 'subagent' readonly delegationDepth?: number + readonly agentPreset?: string } } ``` diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index efb01427b4..6099ceac46 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -77,12 +77,19 @@ interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number + /** + * Id of the agent preset this session's agent was composed from, when the + * deployment composes per session. Durable because the preset decides the + * session's tools and prompt: a resume that restored a different composition + * would replay history the model can no longer act on. + */ + readonly agentPreset?: string } ``` ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 +通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth`、该 agent 所依据组装的 `agentPreset` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 ```ts type-equiv /** @@ -104,6 +111,7 @@ interface CreateSessionOptions { readonly seedLength?: number readonly origin?: 'subagent' readonly delegationDepth?: number + readonly agentPreset?: string } } ``` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 93b01139a8..1854ccd33f 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -77,7 +77,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:323`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:351`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:383`](../packages/core/session/src/types.ts) ## Events @@ -174,7 +174,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -190,7 +190,7 @@ Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/ Types: [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) ### `command/*` @@ -446,7 +446,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -458,7 +458,7 @@ Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -511,7 +511,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -547,7 +547,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:43`](../packages 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -556,7 +556,7 @@ Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -586,7 +586,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) ### `tool/*` @@ -603,7 +603,7 @@ Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -676,7 +676,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/c } ``` -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) ### `turn/*` @@ -696,7 +696,7 @@ Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -710,7 +710,7 @@ Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) ### `user/*` @@ -727,7 +727,7 @@ Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d296b1b519..2396d5725a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1865,7 +1865,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'CreateGoalRequest', @@ -1873,7 +1873,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateSessionOptions', - declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n}', + declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n };\n}', }, { name: 'CredentialInfo', @@ -2525,7 +2525,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionHeader', - declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n}', + declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n}', }, { name: 'SessionId', diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 55cb94d8f9..b5b6161a1b 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -84,6 +84,7 @@ export interface CreateAgentOptions { readonly seedLength?: number readonly origin?: 'subagent' readonly delegationDepth?: number + readonly agentPreset?: string } /** * Initial replay/fork history. A fork supplies a balanced completed-turn diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index d250998624..6d05df300d 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -142,6 +142,9 @@ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader { && (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) { throw new Error('session header delegationDepth must be a non-negative safe integer') } + if (record.agentPreset !== undefined && typeof record.agentPreset !== 'string') { + throw new Error('session header agentPreset must be a string') + } return deepFreeze(record as unknown as SessionHeader) } @@ -882,6 +885,7 @@ export class SessionStore extends Service { ...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength }, ...meta?.origin === undefined ? {} : { origin: meta.origin }, ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth }, + ...meta?.agentPreset === undefined ? {} : { agentPreset: meta.agentPreset }, } return Session.create(sessionId, seed, header) } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 854e28d2e7..dd4c100d6d 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -69,6 +69,13 @@ export interface SessionHeader { * resume — a runtime-only depth would reset a resumed child to top-level. */ readonly delegationDepth?: number + /** + * Id of the agent preset this session's agent was composed from, when the + * deployment composes per session. Durable because the preset decides the + * session's tools and prompt: a resume that restored a different composition + * would replay history the model can no longer act on. + */ + readonly agentPreset?: string } /** @@ -90,6 +97,7 @@ export interface CreateSessionOptions { readonly seedLength?: number readonly origin?: 'subagent' readonly delegationDepth?: number + readonly agentPreset?: string } } diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 426860eebc..58247720ec 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -63,13 +63,15 @@ "zod": "^4.4.3" }, "peerDependencies": { - "cordis": "^4.0.0-rc.7", - "@deepseek-ai/dsh-invariants": "^0.0.1" + "@deepseek-ai/dsh-agent-presets": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", - "cordis": "^4.0.0-rc.7", - "@deepseek-ai/dsh-invariants": "workspace:^" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f528b2297e..12d15af124 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -24,6 +24,7 @@ import { WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). +import { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-tools' import type { ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, @@ -744,6 +745,45 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro targetFor(agent) } + /** + * Resolve the preset an agent will be composed from, and the setup that + * installs it. + * + * The id is resolved BEFORE the session exists because the session boundary + * snapshots `meta` before asynchronous setup begins — a preset discovered + * during setup could never reach the header. Mounting still happens in + * setup, where a failure rolls the whole creation back rather than leaving a + * published session whose capabilities are half-installed. + * + * A deployment with no preset roster composes nothing and every session + * shares the host composition, which is the behavior before presets existed. + * @param presetId - the requested preset, or `undefined` for the default. + * @returns the id to record on the header (absent without a roster) and the setup callback. + * @throws when the roster supplies no such preset. + */ + async function composeAgent(presetId: string | undefined): Promise<{ + agentPreset?: string + setup: (agentCtx: Context) => Promise + }> { + const presets = ctx.get('agentPresets') + if (presets === undefined) { + return { + setup: (agentCtx: Context) => { + installTarget(agentCtx) + return Promise.resolve() + }, + } + } + const resolvedId = (await presets.resolve(presetId)).id + return { + agentPreset: resolvedId, + setup: async (agentCtx: Context) => { + installTarget(agentCtx) + await presets.mount(agentCtx, resolvedId) + }, + } + } + /** Send one transient frame to every connected mux consumer. */ function broadcast(payload: MuxFrame): void { const envelope = frame(payload) @@ -1101,7 +1141,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } /** Resolve one requested identity to a live agent, creating or resuming it once. */ - async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise { + async function ensureSession( + sessionId: SessionId, + cwd: string, + checkPersistedIdentity: boolean, + presetId?: string, + ): Promise { let creation = sessionCreations.get(sessionId) if (creation === undefined) { creation = (async () => { @@ -1127,10 +1172,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (inspected.meta.cwd !== cwd) { throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd) } + // The stored preset wins over anything the request names: a resumed + // session's history was produced under that composition, and + // rebuilding it differently would replay tool calls the model can no + // longer make. return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, - setup: installTarget, + setup: (await composeAgent(inspected.meta.agentPreset)).setup, })).agent } @@ -1139,11 +1188,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } catch (error: unknown) { throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error }) } + const composition = await composeAgent(presetId) return (await ctx.agents.create({ sessionId, agentOptions, - meta: { cwd }, - setup: installTarget, + meta: { + cwd, + ...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }, + }, + setup: composition.setup, })).agent })().catch((error: unknown) => { // Another Host entry path may have published the same identity while @@ -1592,9 +1645,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd + const requestedPreset = request.payload.agentPreset try { - await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined) + await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset) } catch (error: unknown) { + if (error instanceof UnknownPresetError) { + return err(request, { + code: 'agent-preset-not-found', + message: error.message, + details: { agentPreset: error.presetId, available: [...error.available] }, + }) + } + if (error instanceof PresetMountError) { + return err(request, { + code: 'agent-preset-invalid', + message: error.message, + details: { agentPreset: error.presetId, reason: error.reason }, + }) + } if (error instanceof SessionCwdConflict) { return err(request, { code: 'session-conflict', diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 2733c6e940..5b9c7f82a7 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -46,6 +46,8 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), + z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), + z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }), z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 54bbb5a8cc..1a35048b32 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -44,6 +44,8 @@ export interface RpcErrorDetailsMap { 'directory-exists': { path: string } 'directory-create-failed': { path: string } 'directory-picker-unavailable': { capability: string } + 'agent-preset-not-found': { agentPreset: string; available: string[] } + 'agent-preset-invalid': { agentPreset: string; reason: string } 'agent-busy': { reason: string } 'queue-item-not-found': { itemId: MessageId } 'steer-unavailable': { itemId: MessageId } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 9f9c4329e6..a3c7845aad 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -100,6 +100,7 @@ export const sessionCreateRequestSchema = z.object({ workspaceId: workspaceIdSchema.optional(), cwd: z.string().optional(), sessionId: sessionIdSchema.optional(), + agentPreset: z.string().optional(), }).refine( payload => payload.workspaceId === undefined || payload.cwd === undefined, { message: 'session.create accepts workspaceId or cwd, not both' }, diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 18315eef19..aeb3e933db 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -199,8 +199,14 @@ export interface SessionsApi { * session, while a different cwd fails with `session-conflict`. Workspace * creation attaches the session after publication; an attach failure * returns `workspace-attach-failed` with the published session id. + * + * `agentPreset` names the composition the new session's agent is built + * from; omitted, the deployment's default preset applies. The resolved id + * is stored on the session header, so a later resume rebuilds the same + * agent. An unknown id fails with `agent-preset-not-found`, and a preset + * whose composition cannot be mounted fails with `agent-preset-invalid`. */ - create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>): + create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId; agentPreset?: string }>): Promise> /** diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index c648d7a30d..2911c002ab 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../core/agent" }, + { + "path": "../../preset/agent-presets" + }, { "path": "../../core/session" }, diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 778b0d7275..ff0f0fe116 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -14,10 +14,11 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { discoverPresets } from './discovery.ts' import { mountPreset } from './mount.ts' -import type { AgentPreset, Config } from './types.ts' +import { UnknownPresetError, type AgentPreset, type Config } from './types.ts' export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' export { inactiveRows, leakedServices, livePresetMounts, mountPreset, type PresetMount } from './mount.ts' +export { PresetMountError, UnknownPresetError } from './types.ts' export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts' declare module 'cordis' { @@ -73,8 +74,7 @@ export class AgentPresets extends Service { const presets = await this.list() const found = presets.find(preset => preset.id === wanted) if (found === undefined) { - const known = presets.map(preset => preset.id).join(', ') - throw new Error(`agent-presets: preset "${wanted}" not found (available: ${known || 'none'})`) + throw new UnknownPresetError(wanted, presets.map(preset => preset.id)) } return found } diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index 39d55b17e6..2563b623f3 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -19,7 +19,7 @@ import { Context, type Fiber } from 'cordis' import { Include } from '@cordisjs/plugin-include' import type { EntryTree } from '@cordisjs/plugin-loader' import { scopeOf } from '@deepseek-ai/dsh-scope' -import type { AgentPreset } from './types.ts' +import { PresetMountError, type AgentPreset } from './types.ts' /** What one mounted subtree publishes about itself for the audit to read. */ interface MountedTree { @@ -221,6 +221,6 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi wraps a row's thrown value before it propagates, and this module's own rejections are Errors. The fallback keeps a hostile value readable. */ const detail = error instanceof Error ? error.message : String(error) - throw new Error(`agent-presets: preset "${preset.id}" (${preset.path}) failed to mount: ${detail}`, { cause: error }) + throw new PresetMountError(preset.id, `${detail} (${preset.path})`, { cause: error }) } } diff --git a/packages/preset/agent-presets/src/types.ts b/packages/preset/agent-presets/src/types.ts index 64fa2cdf07..975540a44f 100644 --- a/packages/preset/agent-presets/src/types.ts +++ b/packages/preset/agent-presets/src/types.ts @@ -32,3 +32,34 @@ export interface Config { /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ roots: PresetRoot[] } + +/** + * No configured root supplies the requested preset. + * + * Separate from a mount failure because the two mean different things to a + * caller: an unknown id is a bad request, while an unusable composition is a + * broken preset the deployment must fix. + */ +export class UnknownPresetError extends Error { + constructor( + /** The id that was requested. */ + readonly presetId: string, + /** Ids the roster does supply, for the caller to offer instead. */ + readonly available: readonly string[], + ) { + super(`agent-presets: preset "${presetId}" not found (available: ${available.join(', ') || 'none'})`) + } +} + +/** A preset exists but its composition cannot be installed. */ +export class PresetMountError extends Error { + constructor( + /** The preset whose composition failed. */ + readonly presetId: string, + /** Why it failed, without this package's own message prefix. */ + readonly reason: string, + options?: ErrorOptions, + ) { + super(`agent-presets: preset "${presetId}" failed to mount: ${reason}`, options) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bba564577..914238e3d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3669,6 +3669,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants