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
87 changes: 77 additions & 10 deletions assets/.dsh/profiles/agent-compose/cordis.patch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,56 @@
# runtime/javascript/src/runners/dsh.ts — see docs/design/dsh_agent_provider_design.md
# §3.2/§3.5. This file ships as a repo asset (assets/.dsh/...), not an npm package.

# dsh-base mounts two LLM adapters: llm-deepseek (its native one, active) and
# llm-pi-ai (dormant until a profile supplies routes). agent-compose uses the
# latter because llm-deepseek speaks only chat completions — its Config has no
# protocol field at all — which forces a protocol conversion whenever the
# resolved provider serves something else. A conversion also carries whatever
# the upstream sends that the bridge does not model: this gateway emits its own
# codex.response.metadata and responsesapi.websocket_timing events, which the
# chat-completions encoder rendered into the assistant's answer.
# llm-pi-ai names its wire protocol per route, so the guest can speak whatever
# the facade resolved and the request stays on the passthrough path.
#
# Disabling llm-deepseek keeps one adapter owning the route namespace: two
# adapters claiming the same provider id fail plugin loading.
- id: llm-deepseek
disabled: true

# A hand-declared route: pi-ai ships nothing under this key, so the profile
# supplies the whole provider. Such a route requires api, baseURL, and a
# non-empty models list. All three come from the spawn environment.
# DSH_WIRE_API is the protocol the daemon's facade resolved for this run —
# openai-responses, openai-completions or anthropic-messages, following the
# resolved provider's family (see EnsureDshFacadeConfig).
- id: llm-pi-ai
config:
apiKeyEnv: LLM_API_KEY
baseURL: !!js process.env.LLM_API_ENDPOINT
thinking: enabled
reasoningEffort: !!js process.env.DSH_REASONING_EFFORT || 'max'
providers:
agent-compose:
displayName: agent-compose facade
apiKeyEnv: LLM_API_KEY
api: !!js process.env.DSH_WIRE_API || 'openai-completions'
baseURL: !!js process.env.LLM_API_ENDPOINT
# DSH_REASONING_EFFORT is normalized by dsh.ts from agent-compose's
# effort levels and consumed as the route's default reasoning level.
# The 'max' fallback is load-bearing rather than a taste call: the
# llm-deepseek row this route replaces carried a static
# `thinking: enabled` + `reasoningEffort: 'max'`, and no daemon-driven
# path sets DSH_REASONING_EFFORT today (neither the prompt exec spec
# nor the prompt-attach start frame carries an effort). Leaving this
# undefined would silently downgrade every dsh run.
reasoning: !!js process.env.DSH_REASONING_EFFORT || 'max'
models:
- id: !!js process.env.DSH_MODEL || 'deepseek-v4-flash'
reasoningEfforts:
off:
low: low
high: high
max: max

# llm-deepseek registers a single route, 'deepseek-official' (see §4.2); the
# model name alone comes from the spawn environment.
- id: agent-default-model
config:
provider: deepseek-official
provider: agent-compose
model: !!js process.env.DSH_MODEL || 'deepseek-v4-flash'

# agent-compose assembles persona/skills-catalog/workspace context host-side,
Expand All @@ -35,12 +73,13 @@
root: !!js process.env.DSH_SESSION_ROOT

# Local credential discovery is disabled — the only LLM credential is the
# run-scoped facade token injected as LLM_API_KEY (see §5.4). llm-deepseek
# still resolves apiKeyEnv from the launch environment when this row is off.
# run-scoped facade token injected as LLM_API_KEY (see §5.4). The llm-pi-ai
# route still resolves its apiKeyEnv from the launch environment when this row
# is off.
- id: credentials
disabled: true

# $DSH_HOME/settings.yaml can otherwise override llm-deepseek's apiKeyEnv/
# $DSH_HOME/settings.yaml can otherwise override the LLM route's apiKeyEnv/
# baseURL at runtime; disabling this row keeps the facade the only LLM
# credential source (see §5.4).
- id: settings
Expand All @@ -53,6 +92,34 @@
includeDefaultRoots: false
customSkillDirs: !!js (process.env.DSH_SKILL_DIRS || '').split(':').filter(Boolean)

# dsh-base confines tool execution itself: bash-sandbox, pwsh-sandbox and
# fs-sandbox enforce a mode, and sandbox-policy pins it from
# DSH_PERMISSION_MODE. agent-compose already runs the whole guest inside its
# own sandbox, so that inner layer enforces nothing extra — it only adds a
# `sandbox_permissions` escalation argument to every tool schema.
#
# That argument is unusable here and actively costly. Its enum is advertised
# whenever a confining executor is mounted, but nothing tells the model which
# mode it currently holds, so a model reaches for it, asks for a mode narrower
# than the danger-full-access it already has, and approveEscalation rejects the
# call as "not strictly wider" — one wasted turn per occurrence. See
# https://github.com/deepseek-ai/deepseek-harness/discussions/468.
#
# Swapping in the unconfined executors drops `ctx.shell.sandboxMode`, the gate
# the tools read: with no mode the escalation fields leave the schema entirely,
# so the model cannot ask for something it already holds. Narrowing the mode
# instead would be worse, since dsh-base derives the approval policy from the
# same variable ('never' only under danger-full-access) and this guest has no
# one to answer an approval prompt.
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-local'

- id: pwsh-sandbox
name: '@deepseek-ai/dsh-pwsh-local'

- id: fs-sandbox
name: '@deepseek-ai/dsh-fs-local'

# No approval/sandbox-policy overrides here: dsh-base's own rows already key
# off DSH_PERMISSION_MODE (agent-compose always sets danger-full-access — see
# §5.3/§5.5, guest sandboxing is provided by the agent-compose sandbox, not a
Expand Down
19 changes: 12 additions & 7 deletions docs/design/dsh_agent_provider_design.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,26 +39,31 @@ Env vars aren't unbounded: Linux caps a single `argv`/`envp` string at `MAX_ARG_
| Variable | Set by | Purpose |
| --- | --- | --- |
| `DSH_MODEL` | `dsh.ts` | Model name (provider routing is resolved host-side; only the model literal crosses) |
| `DSH_REASONING_EFFORT` | `dsh.ts` | agent-compose's 5-level `effort` collapsed to DSH's 2-level `high`/`max` (§6 has no equivalent collapse — this is the reasoning-effort case) |
| `DSH_REASONING_EFFORT` | `dsh.ts` | agent-compose's 5-level `effort` collapsed onto the `low`/`high`/`max` the `llm-pi-ai` route declares (§6 has no equivalent collapse — this is the reasoning-effort case). No daemon-driven path sets an effort today, so the route's `'max'` fallback is what every run actually gets; it preserves the static `thinking: enabled` + `reasoningEffort: 'max'` the replaced `llm-deepseek` row carried |
| `DSH_PERMISSION_MODE` | facade config + `dsh.ts` | Always `danger-full-access`; guest sandboxing is the agent-compose sandbox, not a nested DSH one (§5.3/§5.5) |
| `DSH_SESSION_ROOT`, `DSH_SESSION_ID`, `DSH_RESUME` | `dsh.ts` | Session persistence and resume (§3.3) |
| `DSH_PROMPT_FILE` | `dsh.ts` | Path to the prompt text file `runner.js` reads |
| `DSH_SYSTEM_CONTEXT_FILE` | `dsh.ts` | Path to the persona text file `runner.js` reads and injects (§7); unset when there's no system context |
| `DSH_SKILL_DIRS` | `dsh.ts` | Colon-joined resolved skill directories; consumed by the `skill-filesystem` row's `customSkillDirs` (§5.1) |
| `DSH_MCP_SERVERS` | `dsh.ts` | JSON array of per-server `dsh-mcp-client` configs; consumed by `runner.js` (§6) |
| `LLM_API_KEY`, `LLM_API_ENDPOINT` | facade config | Consumed by the `llm-deepseek` row (§4) |
| `DSH_WIRE_API` | facade config | The wire protocol the facade resolved for this run (`openai-completions`, `openai-responses` or `anthropic-messages`); consumed by the `llm-pi-ai` route's `api` (§4.1) |
| `LLM_API_KEY`, `LLM_API_ENDPOINT` | facade config | Consumed by the `llm-pi-ai` route's `apiKeyEnv`/`baseURL` (§4) |

`env` starts from `...process.env`, so a key this run has no value for isn't automatically absent — it's whatever the host process happened to export. Every conditional `DSH_*` var (`DSH_SYSTEM_CONTEXT_FILE`, `DSH_MCP_SERVERS`, `DSH_RESUME`, `DSH_MODEL`, `DSH_REASONING_EFFORT`, `DSH_SKILL_DIRS`) is therefore explicitly `delete`d in its false branch rather than left conditionally-set, so a host-inherited value can't leak through as this run's persona file, MCP server list, resume flag, model, effort, or skill directories. `DSH_SKILL_DIRS` is the sharpest case: an inherited value would have `dsh` load a skill directory `resolveSkillPaths()`'s symlink-escape check never saw, under `danger-full-access` permissions.
`env` starts from `...process.env`, so a key this run has no value for isn't automatically absent — it's whatever the host process happened to export. Every conditional `DSH_*` var (`DSH_SYSTEM_CONTEXT_FILE`, `DSH_MCP_SERVERS`, `DSH_RESUME`, `DSH_REASONING_EFFORT`, `DSH_SKILL_DIRS`) is therefore explicitly `delete`d in its false branch rather than left conditionally-set, so a host-inherited value can't leak through as this run's persona file, MCP server list, resume flag, effort, or skill directories. `DSH_MODEL` is the deliberate exception: the inherited value is the one the daemon's facade config exported for the model it minted the token against, so `dsh.ts` overwrites it only when the invocation names a model of its own and never deletes it. `DSH_SKILL_DIRS` is the sharpest case: an inherited value would have `dsh` load a skill directory `resolveSkillPaths()`'s symlink-escape check never saw, under `danger-full-access` permissions.

## 4. LLM facade routing

### 4.1 Facade token and wire protocol

`EnsureDshFacadeConfig` (`pkg/llms/dsh_facade.go`) always issues a chat-completions facade token and points the guest at `/llm/openai/v1`, regardless of the resolved upstream provider's own protocol the facade bridges the difference, so DSH's own upstream protocol is irrelevant to the guest. Model selection is `<llm-provider-id>/<model-name>` (`SplitDshModel`), the same shape Pi and OpenCode use.
`EnsureDshFacadeConfig` (`pkg/llms/dsh_facade.go`) issues a facade token whose wire API **follows the resolved provider**, and exports the same choice as `DSH_WIRE_API` for the profile's `llm-pi-ai` route to name its protocol. Matching the provider keeps the request on the proxy's passthrough path instead of the conversion path, where an upstream event the bridge does not model would reach the guest as assistant text. It was unconditionally chat-completions while the profile used `llm-deepseek`, whose Config has no protocol field at all (see §4.2). Model selection is `<llm-provider-id>/<model-name>` (`SplitDshModel`), the same shape Pi and OpenCode use; an agent naming no model falls back to the daemon's default catalog entry.

### 4.2 `llm-deepseek` route
### 4.2 LLM adapter and route

`cordis.patch.yml`'s `llm-deepseek` row registers a single route, `deepseek-official`, reading its API key/base URL/reasoning effort from the spawn environment. `agent-default-model` selects `deepseek-official` + `DSH_MODEL`.
`cordis.patch.yml` disables dsh-base's `llm-deepseek` row and configures `llm-pi-ai` instead, which dsh-base mounts dormant until a profile supplies routes.

`llm-deepseek` is DSH's native adapter and speaks only chat completions — its Config exposes `apiKeyEnv`, `baseURL`, `thinking` and `reasoningEffort`, and no protocol field — so any provider serving something else forced a conversion on every turn. `llm-pi-ai` names its wire protocol per route (`openai-completions`, `openai-responses`, `anthropic-messages`), so the guest can speak whatever the facade resolved.

The profile declares one hand-declared route, `agent-compose`: pi-ai ships nothing under that key, so the route supplies `api` (from `DSH_WIRE_API`), `baseURL`, and a `models` list, all from the spawn environment. `agent-default-model` selects that route + `DSH_MODEL`.

## 5. Security and isolation

Expand All @@ -68,7 +73,7 @@ Env vars aren't unbounded: Linux caps a single `argv`/`envp` string at `MAX_ARG_

### 5.2 Model/provider resolution

Resolution mirrors Pi's (`resolveDshFacadeTarget` mirrors `resolvePiFacadeTarget`'s branch structure: configured provider id → family → custom OpenAI), minus an Anthropic-family branch — `llm-deepseek` always speaks chat completions, so there is nothing to mirror there.
Resolution mirrors Pi's: `resolveDshFacadeTarget` mirrors `resolvePiFacadeTarget`'s branch structure (configured provider id → family → custom OpenAI), Anthropic-family branch included. `dshFacadeProtocol` then mirrors `piFacadeProtocol`, routing an Anthropic provider to the `/llm/anthropic` facade endpoint with an `anthropic-messages` token rather than bridging it down to chat completions.

### 5.3 Sandbox policy / permission mode

Expand Down
Loading
Loading