diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml new file mode 100644 index 0000000000..bf681dd0a8 --- /dev/null +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -0,0 +1,260 @@ +# The `cordis` agent preset: the standard coding agent, plus the ability to +# read and write the runtime it is running in. +# +# It exists so a person can ask an agent to author another agent. Everything in +# `standard` is here unchanged; what is added is the self-referential Cordis +# toolset, a skill that teaches composition authoring, and a persona that says +# which of the two planes an edit belongs to. +# +# TRUST: `cordis_mount` evaluates model-written JavaScript against the live +# runtime, and a composition this agent writes becomes a preset other sessions +# mount. Treat a session on this preset as shell access — the toolset's own +# documentation makes the same statement. + + +# 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, running on the DeepSeek Harness. Your working directory is {{cwd}}. + + You can read and modify the harness you run on. Its composition is Cordis: every capability is a plugin row in a `cordis.yml`, and an agent preset is one such file mounted for a single session. + + Two planes decide where an edit belongs. The HOST composition holds the registries and anything shared across sessions — persistence, the sandbox and approval stack, the model route. An AGENT PRESET holds what one session contributes to those registries: its tools, its persona, its delegation backends. A row that publishes a service belongs in the host composition, or inside an `isolate` realm if the preset genuinely owns that service. + + Load the `editing-cordis-compositions` skill before writing or changing a composition. + +- 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: + # The registry and its consumer share the realm: a consumer left outside + # would resolve the host's `bashEnv`, which this plane no longer provides. + - id: bash-env + name: '@deepseek-ai/dsh-bash-env' + + - 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' + +# ── 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 + +# ── self-modification ─────────────────────────────────────────────────────── + +# Read the live runtime, mount a temporary plugin, unmount it. The toolset is a +# trust boundary, not a sandbox — see this file's header. +- id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' + +# The composition-authoring skill travels with this preset rather than living +# in the user's skill root: it documents THIS deployment's two planes, and a +# preset is the unit that gets copied and edited. `baseUrl` is the preset's +# own directory, so the root resolves wherever the preset is installed. +- 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' + config: + customSkillDirs: + - !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))" + + - id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md new file mode 100644 index 0000000000..dc6faea6b1 --- /dev/null +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -0,0 +1,57 @@ +--- +name: editing-cordis-compositions +description: Use when creating or changing a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, or diagnosing a row that mounted but contributed nothing. +--- + +# Editing Cordis compositions + +Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. + +## Decide the plane first + +Two planes, and the choice is not about how "agent-related" something feels — it is about whether the thing must be shared. + +**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, and the model route. One instance for the process. + +**Agent preset.** What one session contributes to those registries: its tool plugins, its persona, its delegation backends, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. + +A preset is a directory holding one `agent.cordis.yml`. The shipped ones live beside the deployment's composition; locally authored ones live under `$DSH_HOME/.agent-presets//`. + +## The rule that catches people + +**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later. + +Whether a row publishes a service is not visible from its name. `tool-bash` reads like a tool but provides `bashEnv`. Check the package's README, or mount the preset and read the rejection — it names the offending service. + +When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm: + +```yaml +- 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' +``` + +`true` means a realm private to each mounting session. A string label instead pools one instance across every subtree naming that label — use it only for something genuinely expensive to duplicate. + +A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. That is the quietest failure here: the mount succeeds and a tool is simply missing. + +## Verifying a change + +Read the live runtime with `cordis_inspect` — it reports the services, the plugin fibers, and the registered tools as they actually are, which is the only reliable check that a row did what its name suggests. + +After editing a preset, start a new session on it and confirm the tool list is what you intended. A preset is read at session creation, so an edit never affects a session already running; the file is never written back either, so your composition is exactly what you wrote. + +`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. + +## What not to move into a preset + +`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. diff --git a/apps/cli/tests/web-agent-presets.spec.ts b/apps/cli/tests/web-agent-presets.spec.ts index 8ee2730dfc..6b973d00a6 100644 --- a/apps/cli/tests/web-agent-presets.spec.ts +++ b/apps/cli/tests/web-agent-presets.spec.ts @@ -76,7 +76,7 @@ describe('the shipped Web composition', () => { it('supplies both shipped presets, and only those, from the system root', async () => { const listed = await ctx.agentPresets.list() - expect(listed.map(preset => preset.id).sort()).toEqual(['core-web', 'standard']) + expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'core-web', 'standard']) expect(listed.every(preset => preset.trust === 'system')).toBe(true) expect(ctx.agentPresets.defaultId).toBe('standard') }) @@ -139,6 +139,51 @@ describe('the shipped Web composition', () => { } }) + it('composes the cordis agent with its own toolset', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-cordis'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'cordis').then(() => undefined), + }) + try { + const tools = toolNames(ctx, handle.agent) + // The self-referential toolset is what distinguishes this preset. + expect(tools).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount'])) + // And it keeps the standard agent's own tools rather than replacing them. + expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill'])) + + // The skill registry sits in this preset's entry-local realm, so it is + // invisible to the host AND to the agent's own scope — only the rows + // inside that group resolve it, which is what makes `tool-skill` the + // agent's own rather than a shared one. + expect(ctx.get('skills')).toBeUndefined() + } finally { + await handle.dispose() + } + }) + + it('keeps the self-referential toolset out of every other preset', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-no-cordis'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + // Editing the live runtime is opt-in per session, not ambient. + expect(toolNames(ctx, handle.agent)).not.toContain('cordis_mount') + } finally { + await handle.dispose() + } + }) + + it('ships the composition-authoring skill inside the preset directory', async () => { + // The preset's skill root is derived from its own `baseUrl`, so the skill + // travels with the directory wherever the preset is installed. + const skill = join( + CONFIG_DIR, 'agent-presets', 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md', + ) + + expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true) + }) + it('never rewrites the preset file it composed from', async () => { // The Loader persists a tree whose plugin self-disposed, and tearing an // agent down disposes its whole subtree. Inherited, that rewrote the diff --git a/packages/preset/README.i18n.yaml b/packages/preset/README.i18n.yaml index 1a789aad5a..f756692364 100644 --- a/packages/preset/README.i18n.yaml +++ b/packages/preset/README.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 packages/preset/README.md -README.md: d2ed10014af506809b5fd117e08b09b45a31a16c -README.zh.md: db7bf18e6ba841cc705eafae7c09d4c5d26581b1 +README.md: d6d51b4d83a85fed3f690334e9f5704567da83a9 +README.zh.md: 6485caa03856e24232648dc36dfbc07d1d6aa6f8 diff --git a/packages/preset/README.md b/packages/preset/README.md index d2ed10014a..d6d51b4d83 100644 --- a/packages/preset/README.md +++ b/packages/preset/README.md @@ -9,6 +9,8 @@ An **agent preset** is a directory holding one `agent.cordis.yml`. Mounting it u | `agent-presets/` | Preset vocabulary, filesystem discovery over trusted and user-authored roots, and the guarded per-agent mount | `ctx.agentPresets` | | `persona/` | The agent persona as a composable row, so a preset can change identity and not only tools | — | +The deployment ships `standard` (the full coding agent), `core-web` (a two-tool benchmark surface), and `cordis` (the standard agent plus the self-referential toolset and a composition-authoring skill, so a person can ask an agent to author another agent). + The composition split this group assumes: registries and cross-session facilities are process singletons and stay in the host composition, while a preset carries what one agent contributes to them. A preset that names a row publishing a process-global service is rejected at mount rather than allowed to collide with the next session. Design: [the per-session agent-preset note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md). diff --git a/packages/preset/README.zh.md b/packages/preset/README.zh.md index db7bf18e6b..6485caa038 100644 --- a/packages/preset/README.zh.md +++ b/packages/preset/README.zh.md @@ -9,6 +9,8 @@ | `agent-presets/` | preset 词汇、在受信任目录与用户自建目录上的文件系统发现,以及带校验的按 agent 挂载 | `ctx.agentPresets` | | `persona/` | 把 agent 人设做成可组装的行,使 preset 不止能改工具、也能改身份 | — | +部署随附三个 preset:`standard`(完整编码 agent)、`core-web`(两个工具的 benchmark 表层),以及 `cordis`(标准 agent 加上自指工具集与一份组装创作 skill,使人可以让 agent 去创作另一个 agent)。 + 本组假定的组装划分是:注册表与跨会话设施是进程单例,留在宿主组装中;preset 只承载单个 agent 对它们的贡献。若 preset 中某一行发布了进程级全局服务,挂载时即被拒绝,而不是留到与下一个会话相撞。 设计详见 [按会话组装 agent preset 的 Agent Note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md)。