Merge master into compact-tool-result-prune

This commit is contained in:
Tianyi Cui
2026-07-20 16:41:59 +08:00
807 changed files with 15501 additions and 3177 deletions

View File

@@ -2,7 +2,7 @@
Agent Client Protocol bridge over JSON-RPC stdio. Editors can create or resume agents, stream their events, answer questions and approvals, and render tool calls. One connection supports multiple isolated sessions; Zed is the primary compatibility target.
It is a **client-driver / UI plugin**, the structured analogue of the terminal `dsh-tui`/`dsh-stdio` channels — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
It is a **client-driver / UI plugin**, the structured analogue of the terminal `dsh-tui`/`dsh-stdio` channels — NOT a loop change and NOT a [capability seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
## Service / plugin
@@ -37,13 +37,13 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
## Multi-session
One id-keyed record map plus exact agent-object checks route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md).
One id-keyed record map plus exact agent-object checks route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session Agent Note](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md).
## Session config options
The bridge advertises a `model`-category select in `session/new` and `session/load` when the session has a complete target whose provider is registered. Values encode the complete provider/model pair, are grouped by provider when more than one group is available, and come from `ctx.llm.listProviders()` / `listModels()`. The configured or last-requested model is added when absent because catalogs are advisory and private adapters may accept unlisted ids. A selection changes only that ACP session. Agent-scoped prompt assembly snapshots the selected pair for one step, supplies matching `{{provider}}` / `{{model}}` variables, and the `agent/request` waterfall applies the same pair; a concurrent selection therefore takes effect on the next step instead of splitting prompt text from routing. The resulting request header is the durable record restored by `session/load`; a selection never used by a request remains in-memory only.
When `ctx.permission` is composed, the bridge also advertises a `permission` select. Options come from the deployment's preset table; the current value comes from the session fold, with switch-away-only `custom` for unmatched knobs. `session/set_config_option` accepts advertised presets and writes both sandbox-mode and approval-policy events through `PermissionService.set()`. Open-turn switches append immediately; idle switches overlay responses and anchor at the next `agent/prompt-submit`, before request assembly. A crash before anchoring restores the durable fold. See the [model-catalog RFC](../../../docs/rfc/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md), [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md), [`dsh-permission`](../permission/README.md), and [protocol matrix](acp-feature-support.md#6-session-modes--config-options--models).
When `ctx.permission` is composed, the bridge also advertises a `permission` select. Options come from the deployment's preset table; the current value comes from the session fold, with switch-away-only `custom` for unmatched knobs. `session/set_config_option` accepts advertised presets and writes both sandbox-mode and approval-policy events through `PermissionService.set()`. Open-turn switches append immediately; idle switches overlay responses and anchor at the next `agent/prompt-submit`, before request assembly. A crash before anchoring restores the durable fold. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md), [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md), [`dsh-permission`](../permission/README.md), and [protocol matrix](acp-feature-support.md#6-session-modes--config-options--models).
The shared [`ctx.tasks` runtime](../../tasks/tasks/) fences access to predictable task ids by the owning session; ACP sessions therefore cannot read or stop one another's background work.
@@ -57,7 +57,7 @@ Tools return provider-neutral `generic`, `terminal`, or `diff` render intents fr
## Terminal card (capability-gated)
When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md).
## Settle-exactly-once
@@ -73,7 +73,7 @@ Disposal and client disconnect share one memoized teardown. It cancels pending p
## stdout is the protocol
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../../docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging.
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../../.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging.
## Running
@@ -94,37 +94,77 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa
### User messages
**What the model sees**: Each ACP `session/prompt` becomes an agent user message: text passes through verbatim and each `resource_link` becomes exactly a leading newline, `[resource_link name=<JSON-string> uri=<JSON-string>]`, and a trailing newline. Unsupported image, audio, and embedded-resource blocks are rejected rather than silently omitted.
#### What the model sees
**Token effect**: Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions keep separate contexts.
Each ACP `session/prompt` becomes an agent user message: text passes through verbatim and each `resource_link` becomes exactly a leading newline, `[resource_link name=<JSON-string> uri=<JSON-string>]`, and a trailing newline. Unsupported image, audio, and embedded-resource blocks are rejected rather than silently omitted.
#### Token effect
Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions keep separate contexts.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Human answers and permission decisions
**What the model sees**: When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, and streamed session updates are UI-only.
#### What the model sees
**Token effect**: Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens. A replacement `tool/result` still changes the model-facing session surface, but live and replayed ACP feeds ignore it as an execution update so the original terminal or diff completion is not overwritten.
When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, and streamed session updates are UI-only.
#### Token effect
Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens. A replacement `tool/result` still changes the model-facing session surface, but live and replayed ACP feeds ignore it as an execution update so the original terminal or diff completion is not overwritten.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Permission preset switches
**What the model sees**: `session/set_config_option` emits no model message itself. When `dsh-permission` is composed, the bridge writes the selected preset through that service; the resulting model-visible policy prompt and change notice belong to [`dsh-user-approval`](../user-approval/README.md), while sandbox-mode effects belong to [`dsh-tool-bash`](../../bash/tool-bash/README.md). The ACP `Permissions` select, its option descriptions, pending idle value, and refreshed config response remain client-only.
#### What the model sees
**Token effect**: Zero direct tokens from the ACP option or the log-only `permission/preset` event. Downstream cost is limited to the owning plugins' policy prompt, conditional retained change notice, and any changed tool outcome.
`session/set_config_option` emits no model message itself. When `dsh-permission` is composed, the bridge writes the selected preset through that service; the resulting model-visible policy prompt and change notice belong to [`dsh-user-approval`](../user-approval/README.md), while sandbox-mode effects belong to [`dsh-tool-bash`](../../bash/tool-bash/README.md). The ACP `Permissions` select, its option descriptions, pending idle value, and refreshed config response remain client-only.
#### Token effect
Zero direct tokens from the ACP option or the log-only `permission/preset` event. Downstream cost is limited to the owning plugins' policy prompt, conditional retained change notice, and any changed tool outcome.
#### KV Cache effect
The ACP option and log event cause no direct invalidation. The downstream policy-prompt change may invalidate reuse from that system section, while its change notice appends to history.
### Model switches
**What the model sees**: The ACP selector itself emits no message. The selected provider/model pair supplies the next step's `{{provider}}` / `{{model}}` prompt variables and request routing together; all other call-config fields continue through the `agent/request` waterfall unchanged.
#### What the model sees
**Token effect**: The selector adds no direct tokens. A changed model may tokenize the same retained prompt/history differently, and any persona text that interpolates provider or model changes accordingly.
The ACP selector itself emits no message. The selected provider/model pair supplies the next step's `{{provider}}` / `{{model}}` prompt variables and request routing together; all other call-config fields continue through the `agent/request` waterfall unchanged.
#### Token effect
The selector adds no direct tokens. A changed model may tokenize the same retained prompt/history differently, and any persona text that interpolates provider or model changes accordingly.
#### KV Cache effect
Switching provider or model selects a different cache domain. If the persona interpolates either value, the rendered system prompt also changes and prevents reuse from its first changed token.
### Loaded sessions
**What the model sees**: `session/load` resumes the persisted log, after which the loop sends its reconstructed history and request header. Replaying that log to the editor is not an extra model message.
#### What the model sees
**Token effect**: Restored context has the persistence and session packages' normal retained cost; ACP replay to the client adds none.
`session/load` resumes the persisted log, after which the loop sends its reconstructed history and request header. Replaying that log to the editor is not an extra model message.
#### Token effect
Restored context has the persistence and session packages' normal retained cost; ACP replay to the client adds none.
#### KV Cache effect
Loading does not rewrite the stored log, but the next request is reconstructed under the current envelope and route. Reuse requires that reconstruction to match; ACP replay to the client has no cache effect.
## Known Limitations and Deferred Work
- **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented.
- **Prompt content is `text` + `resource_link` only** — image, audio, and embedded-resource blocks are rejected, as is a non-empty `mcpServers` list at `session/new`.
- **Terminal cards render completed output** — live incremental streaming and command classification are named follow-ups of [the terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
- **Terminal cards render completed output** — live incremental streaming and command classification are named follow-ups of [the terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
- **Permission answers are one-shot only** — the bridge offers `allow_once` / `reject_once`; durable `allow_always` grants and their storage/revocation policy remain deferred to the approval seam.

View File

@@ -86,13 +86,13 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). |
| `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. |
| `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. |
| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). |
| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox Agent Note § Per-session mode switching](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). |
| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). |
| `session_info_update` | S | ❌ | ⚠️ | ⚠️ | Session title/metadata not pushed. |
## 5. Tool-call rendering
Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult` on the `dsh-tools` definition), not special-cased in the bridge — see the [terminal-and-tool-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult` on the `dsh-tools` definition), not special-cased in the bridge — see the [terminal-and-tool-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
| Feature | Stable | Bridge | Claude | Codex | Notes |
|---|---|---|---|---|---|
@@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
## 6. Session modes / config options / models
Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. Session modes stay deliberately unmodeled because config options replace them in ACP v2. See the [model-catalog RFC](../../../docs/rfc/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. Session modes stay deliberately unmodeled because config options replace them in ACP v2. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
## 7. Content blocks
@@ -130,7 +130,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them
| Feature | Stable | Bridge | Notes |
|---|---|---|---|
| `StopReason` mapping | S | ✅ | `turnEndToStopReason` is total over harness turn-end reasons → `end_turn`/`max_tokens`/`cancelled`. |
| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md). |
| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session Agent Note](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md). |
| Disconnect / disposal teardown | S | ✅ | Quiesces every live session on client disconnect or Cordis disposal. |
| `_meta` extensibility | S | ⚠️ | Consumed (Zed terminal cap) and emitted (terminal `_meta`); no other custom extensions. |
| Background-task ownership isolation | — | ✅ | Generic `task_output`/`task_kill` reject tasks whose branded owner `SessionId` belongs to another session. |
@@ -156,4 +156,4 @@ Unstable/draft ACP features that **neither** reference adapter ships are not tra
- Stable spec: `schema/v1/schema.json` (schema `1.14.0`) and `docs/protocol/v1/*.mdx` in the [agent-client-protocol](https://github.com/agentclientprotocol/agent-client-protocol) repo.
- Reference adapters: [`claude-agent-acp`](https://github.com/zed-industries/claude-code-acp) and [`codex-acp`](https://github.com/zed-industries/codex-acp).
- Bridge: [`README.md`](README.md), [`src/index.ts`](src/index.ts), and the ACP RFCs under [`docs/rfc/`](../../../docs/rfc/README.md).
- Bridge: [`README.md`](README.md), [`src/index.ts`](src/index.ts), and the ACP Agent Notes under [`.agents/notes/`](../../../.agents/notes/README.md).

View File

@@ -12,14 +12,14 @@ sequenceDiagram
participant Workspace
participant Replay as llm-replay adapter
participant ACP as acp-agent subprocess
participant Golden as stdout golden
participant Expected as stdout expected output
Recorder->>Fixture: session.jsonl + workspace inputs
Fixture->>Workspace: seed files and hook configs
Fixture->>Replay: recorded StreamChunk script
Replay->>ACP: deterministic <code>llm/stream</code> chunks
ACP->>Workspace: bash, fs, and hook side effects
ACP->>Golden: normalized sessionUpdate stream
Golden-->>ACP: diff must be empty
ACP->>Expected: normalized sessionUpdate stream
Expected-->>ACP: diff must be empty
```
The fs and hook snapshot matrix is valuable because it proves world state, hook decisions, and failed tool-card rendering, not just that replay returns text.

View File

@@ -537,7 +537,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
// the fail-closed `unavailable` default) takes the question. A rejected
// `requestPermission` (client gone, bridge torn down) propagates and the
// ApprovalService contains it as `unavailable`. Options are one-shot only:
// allow_always is a grant-storage design the approval RFC defers, so the
// allow_always is a grant-storage design the approval Agent Note defers, so the
// prompt never offers a durable grant the harness could not honor.
ctx.on('approval/request', (req, next) => {
const rec = ownedRecord(req.agent)
@@ -627,7 +627,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
const protocolVersion = params.protocolVersion === PROTOCOL_VERSION ? params.protocolVersion : PROTOCOL_VERSION
// Remember the Zed terminal-output `_meta` capability: when set, bash and
// other shell tools render as a terminal card (see streamSessionEventUpdate
// + the terminal-rendering RFC). `_meta` is `{[k]: unknown} | null`, so
// + the terminal-rendering Agent Note). `_meta` is `{[k]: unknown} | null`, so
// narrow defensively to a strict boolean true.
terminalOutputCap = params.clientCapabilities?._meta?.['terminal_output'] === true
return Promise.resolve({

View File

@@ -199,12 +199,12 @@ describe('acp bridge — session config options', () => {
expect(after.configOptions).toEqual(optionsWithPermission('danger-full-access'))
const session = h.ctx.agents.list()[0]?.session
expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'sandbox/mode' || e.type === 'approval/policy')).toBe(false)
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = session?.events ?? []
expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }])
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }])
expect(events.filter(e => e.type === 'sandbox/mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }])
expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }])
const turnStart = events.findIndex(e => e.type === 'turn/start')
const anchored = events.findIndex(e => e.type === 'permission/preset')
@@ -234,7 +234,7 @@ describe('acp bridge — session config options', () => {
expect(back.configOptions).toEqual(optionsWithPermission('workspace-write'))
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = h.ctx.agents.list()[0]?.session.events ?? []
expect(events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
expect(events.some(e => e.type === 'permission/preset' || e.type === 'sandbox/mode' || e.type === 'approval/policy')).toBe(false)
})
it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => {
@@ -262,7 +262,7 @@ describe('acp bridge — session config options', () => {
const anchored = events.findIndex(e => e.type === 'permission/preset')
expect(turnStart).toBeGreaterThanOrEqual(0)
expect(anchored).toBeGreaterThan(turnStart)
expect(events.some(e => e.type === 'bash/sandbox-mode')).toBe(true)
expect(events.some(e => e.type === 'sandbox/mode')).toBe(true)
expect(events.some(e => e.type === 'approval/policy')).toBe(true)
await h.client.cancel({ sessionId })
await hung
@@ -332,7 +332,7 @@ describe('acp bridge — session config options', () => {
const agent = h.ctx.agents.list()[0]
if (agent === undefined) throw new Error('expected an agent')
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
agent.session.append('bash/sandbox-mode', { mode: 'read-only' })
agent.session.append('sandbox/mode', { mode: 'read-only' })
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' })
const option = echo.configOptions?.find(entry => entry.id === 'permission')

View File

@@ -20,6 +20,10 @@ This package carries no loader hooks and no dev-mode surface: the `dsh-scripts`
Indirectly, through the plugin tree it loads, which determines the prompts, schemas, messages, and model adapter in the resulting application.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Bare package specifiers depend on Loader internals** — production bins need `node --expose-internals` or the Loader's optional native fallback; an in-process caller without either must use resolvable relative/file specifiers or tsx path mapping.

View File

@@ -8,7 +8,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc
## Config
There are no `cordis.yml` keys. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport seams; production uses process stdio and `process.exit`.
`maxTokensAsSuccess` defaults to `false`. Set it to `true` for evaluation hosts that distinguish an accepted, token-limited agent result from an infrastructure failure. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport seams; production uses process stdio and `process.exit`.
## stdout is the protocol
@@ -26,9 +26,17 @@ The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to qu
### SDK user message
**What the model sees**: For each accepted `session/prompt`, the conversation model receives the caller-supplied `contentBlocks` verbatim as one user message in that SDK session. This package adds no system-prompt prose or tool schema; those come from the plugins in the surrounding `cordis.yml`.
#### What the model sees
**Token effect**: Data-dependent user-message tokens enter retained session history and are resent on later turns until another package compacts them. The JSON-RPC frames, session notifications, and server bookkeeping add zero model-context tokens.
For each accepted `session/prompt`, the conversation model receives the caller-supplied `contentBlocks` verbatim as one user message in that SDK session. This package adds no system-prompt prose or tool schema; those come from the plugins in the surrounding `cordis.yml`.
#### Token effect
Data-dependent user-message tokens enter retained session history and are resent on later turns until another package compacts them. The JSON-RPC frames, session notifications, and server bookkeeping add zero model-context tokens.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work

View File

@@ -1,6 +1,6 @@
/**
* SDK-facing JSON-RPC plugin over stdio. An external `cordis.yml` decides
* whether to load it; see the single-executable RFC and package README.
* whether to load it; see the single-executable Agent Note and package README.
* Stdout is reserved for protocol frames, so the tree must not load a stdout logger.
* This plugin answers `shutdown`, disposes its own fiber, and exits 0; the app bin
* owns EOF and signal exits. Keep named plugin exports with no default export so
@@ -22,8 +22,10 @@ export const name = 'jsonrpc'
// Only the agent factory is required; initialize reads the optional LLM seam with ctx.get().
export const inject = ['agents']
/** Runtime-only test seams; no field is configurable from `cordis.yml`. */
/** JSON-RPC deployment config plus runtime-only test seams. */
export interface JsonRpcConfig {
/** Report max-token turn/subagent termination as a successful SDK result. */
maxTokensAsSuccess?: boolean
/** Transport input override; production uses `process.stdin`. */
input?: Readable
/** Transport output override; production uses `process.stdout`. */
@@ -32,7 +34,9 @@ export interface JsonRpcConfig {
exit?: (code: number) => void
}
export const Config: Schema<JsonRpcConfig> = Schema.object({})
export const Config: Schema<JsonRpcConfig> = Schema.object({
maxTokensAsSuccess: Schema.boolean().default(false),
})
/**
* Serve SDK requests over the configured streams. Effect disposal shuts down
@@ -41,6 +45,8 @@ export const Config: Schema<JsonRpcConfig> = Schema.object({})
* owns root-context disposal for EOF and signals.
*/
export function apply(ctx: Context, config: JsonRpcConfig): void {
// Cordis applies the schema default before invoking the plugin.
const resolvedConfig = config as JsonRpcConfig & { maxTokensAsSuccess: boolean }
// The later transport callback must dispose this plugin's fiber, not its ambient context.
const fiber = ctx.fiber
/* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
@@ -51,7 +57,9 @@ export function apply(ctx: Context, config: JsonRpcConfig): void {
const exit = config.exit ?? ((code: number): void => { process.exit(code) })
const transport = new JsonRpcLineTransport(input, output)
const server = new HarnessSdkServer(ctx, transport)
const server = new HarnessSdkServer(ctx, transport, {
maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess,
})
// Share one exit task and attempt flush and disposal independently before exiting.
let exitTask: Promise<void> | undefined

View File

@@ -57,7 +57,22 @@ function subagentParentOf(carrier: Scoped<SubagentService>): Agent {
return carrierKeyOf(carrier) as Agent
}
/** SDK server whose subscriptions and created agents live until {@link shutdown}. */
/** Deployment-specific status mapping for SDK turn and subagent outcomes. */
export interface HarnessSdkServerOptions {
/** Report max-token termination as an accepted result instead of an infrastructure error. */
maxTokensAsSuccess?: boolean
}
function successStatus(reason: string, options: HarnessSdkServerOptions): 'ok' | 'error' {
if (reason === 'completed') return 'ok'
return reason === 'max-tokens' && options.maxTokensAsSuccess === true ? 'ok' : 'error'
}
/**
* SDK server over one booted harness context and transport peer. Construction
* subscribes to session, agent, and subagent lifecycle events until shutdown;
* reinitialization is unsupported.
*/
export class HarnessSdkServer {
private cwd = process.cwd()
private provider = 'deepseek'
@@ -72,7 +87,9 @@ export class HarnessSdkServer {
constructor(
private readonly ctx: Context,
private readonly transport: JsonRpcTransportPeer,
private readonly options: HarnessSdkServerOptions = {},
) {
const serverOptions = this.options
this.disposers.push(ctx.on('session/event', (session, event) => {
if (event.type === 'turn/end') {
const rec = this.sessions.get(String(session.id))
@@ -99,7 +116,7 @@ export class HarnessSdkServer {
agentId: String(info.id),
parentSessionId: String(parent.session.id),
childSessionId: String(info.id),
status: info.stopReason === 'completed' ? 'ok' : 'error',
status: successStatus(info.stopReason, serverOptions),
stopReason: info.stopReason,
...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }),
})
@@ -233,7 +250,7 @@ export class HarnessSdkServer {
private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' {
if (!reason) return 'error'
return reason.kind === 'completed' ? 'ok' : 'error'
return successStatus(reason.kind, this.options)
}
private hasAdapterFor(provider: string): boolean {

View File

@@ -656,7 +656,7 @@ describe('HarnessSdkServer', () => {
signal: new AbortController().signal,
})
const transport = new FakeTransport()
const server = new HarnessSdkServer(ctx, transport)
const server = new HarnessSdkServer(ctx, transport, { maxTokensAsSuccess: true })
missedStartResult.resolve({ output: [], stopReason: 'max-tokens' })
await missedStartRun.result
@@ -692,7 +692,7 @@ describe('HarnessSdkServer', () => {
agentId: 'fallback-child-session',
parentSessionId: 'fallback-parent',
childSessionId: 'fallback-child-session',
status: 'error',
status: 'ok',
stopReason: 'max-tokens',
lastAssistantMessage: [],
},
@@ -782,6 +782,24 @@ describe('HarnessSdkServer', () => {
}
})
it('can report max-token turn termination as an accepted evaluation result', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-max-tokens-success-'))
const ctx = await makeHarness(storageDir)
try {
const server = new HarnessSdkServer(ctx, new FakeTransport(), { maxTokensAsSuccess: true }) as unknown as {
finishedStatus(reason: unknown): 'ok' | 'error'
shutdown(): Promise<Record<string, never>>
}
expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('ok')
expect(server.finishedStatus({ kind: 'error' })).toBe('error')
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
})
it('reports no adapter when the LLM service is absent', async () => {
const ctx = new Context()
try {

View File

@@ -1,15 +1,19 @@
# @deepseek-ai/dsh-permission
User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `bash/sandbox-mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). The ACP bridge exposes them as one `Permissions` select, while sandbox execution and approval continue to consume their own knobs.
User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). The ACP bridge exposes them as one `Permissions` select, while sandbox execution and approval continue to consume their own knobs.
`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [acp-agent composition](../../../examples/acp-agent/) and [sandbox switching design](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [acp-agent composition](../../../examples/acp-agent/) and [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
## Model Experience
Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet.

View File

@@ -24,6 +24,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -34,6 +35,7 @@
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -12,7 +12,10 @@ import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash'
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
// Side-effect type import: declaration-merges `ctx.bash` (the capability fact
// `sandboxMode` this service reads), without a value dependency on the seam.
import type {} from '@deepseek-ai/dsh-bash'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
@@ -36,7 +39,7 @@ declare module '@deepseek-ai/dsh-session' {
/** One preset's sandbox/approval bundle and optional client presentation. */
export interface PresetSpec {
/** The `bash/sandbox-mode` value the preset writes through. */
/** The `sandbox/mode` value the preset writes through. */
sandbox: SandboxMode
/** The `approval/policy` value the preset writes through. */
approval: ApprovalPolicy

View File

@@ -51,7 +51,7 @@ describe('PermissionService', () => {
it('a knob state matching no table entry derives custom — a state, not an error', async () => {
const ctx = await mounted()
const session = freshSession('sess-custom')
session.append('bash/sandbox-mode', { mode: 'read-only' })
session.append('sandbox/mode', { mode: 'read-only' })
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
ctx.permission.set(session, 'danger-full-access')
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
@@ -74,7 +74,7 @@ describe('PermissionService', () => {
ctx.permission.set(session, 'agentish')
expect(ctx.permission.current(session.events)).toBe('agentish')
session.append('approval/policy', { policy: 'never' })
session.append('bash/sandbox-mode', { mode: 'danger-full-access' })
session.append('sandbox/mode', { mode: 'danger-full-access' })
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
})
@@ -84,7 +84,7 @@ describe('PermissionService', () => {
ctx.permission.set(session, 'danger-full-access')
expect(session.events.map(e => [e.type, e.data])).toEqual([
['permission/preset', { preset: 'danger-full-access' }],
['bash/sandbox-mode', { mode: 'danger-full-access' }],
['sandbox/mode', { mode: 'danger-full-access' }],
['approval/policy', { policy: 'never' }],
])
})
@@ -102,12 +102,12 @@ describe('PermissionService', () => {
ctx.permission.set(session, 'danger-full-access')
// Re-selecting from a drifted state records the choice and repairs only
// the changed knob.
session.append('bash/sandbox-mode', { mode: 'read-only' })
session.append('sandbox/mode', { mode: 'read-only' })
ctx.permission.set(session, 'danger-full-access')
const tail = session.events.slice(4)
expect(tail.map(e => [e.type, e.data])).toEqual([
['permission/preset', { preset: 'danger-full-access' }],
['bash/sandbox-mode', { mode: 'danger-full-access' }],
['sandbox/mode', { mode: 'danger-full-access' }],
])
})

View File

@@ -23,6 +23,9 @@
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../bash/bash"
},

View File

@@ -25,15 +25,31 @@ The plugin seeds display labels from the live agent registry, then tracks `agent
### Readline prompt input
**What the model sees**: Each non-empty terminal line outside an active question becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running.
#### What the model sees
**Token effect**: Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens. A replacement `tool/result` remains model-visible through the session surface but is not rendered as a second execution; stdio keeps the original full-fidelity result line.
Each non-empty terminal line outside an active question becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running.
#### Token effect
Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens. A replacement `tool/result` remains model-visible through the session surface but is not rendered as a second execution; stdio keeps the original full-fidelity result line.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Terminal user-interaction answers
**What the model sees**: When a consumer calls `ctx.userInteraction.ask()`, this provider renders the question in the terminal and returns selected option labels or `custom` text. Through `dsh-tool-ask-user`, closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`; disposal or abort becomes `Error: ask_user_question was interrupted before the user answered`.
#### What the model sees
**Token effect**: Waiting and terminal prompts add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result.
When a consumer calls `ctx.userInteraction.ask()`, this provider renders the question in the terminal and returns selected option labels or `custom` text. Through `dsh-tool-ask-user`, closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`; disposal or abort becomes `Error: ask_user_question was interrupted before the user answered`.
#### Token effect
Waiting and terminal prompts add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work

View File

@@ -23,15 +23,31 @@ This is the consumer package for the user-interaction seam. It does not render U
### Tool schema
**What the model sees**: The model sees the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user), including question ids, prompts, headings, options, and multi-select flags.
#### What the model sees
**Token effect**: Fixed schema cost on every request where the tool is visible.
The model sees the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user), including question ids, prompts, headings, options, and multi-select flags.
#### Token effect
Fixed schema cost on every request where the tool is visible.
#### KV Cache effect
Prefix-stable while the definition and visibility are unchanged. Plugin lifecycle or scoped restrictions may invalidate reuse from this schema.
### Tool-call history and result
**What the model sees**: The model's full questions remain in the assistant tool-call arguments. After the human answers, the next step sees compact JSON in the exact shape `{"answers":[{"id":"<id>","selected":["<label>"],"custom":"<text>"}]}`; `custom` is omitted when unused and `selected` can contain zero, one, or several labels. UI interaction while the call is pending is not model context.
#### What the model sees
**Token effect**: Arguments and answer JSON are data-dependent retained tokens; there is no token cost while waiting for the human.
The model's full questions remain in the assistant tool-call arguments. After the human answers, the next step sees compact JSON in the exact shape `{"answers":[{"id":"<id>","selected":["<label>"],"custom":"<text>"}]}`; `custom` is omitted when unused and `selected` can contain zero, one, or several labels. UI interaction while the call is pending is not model context.
#### Token effect
Arguments and answer JSON are data-dependent retained tokens; there is no token cost while waiting for the human.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work

View File

@@ -2,7 +2,7 @@
The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should compose [`@deepseek-ai/dsh-stdio`](../stdio/README.md) instead.
The implemented [TUI feature RFC](../../../docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot RFC](../../../docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy.
The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy.
This package owns interactive terminal presentation and input only. It injects `agents`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
@@ -47,15 +47,31 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic
### Interactive prompt input
**What the model sees**: Each non-empty editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only.
#### What the model sees
**Token effect**: Submitted text is retained under the agent loop's normal session-history and compaction rules. Headers, cards, Markdown rendering, status lines, plans, and help text add no tokens.
Each non-empty editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only.
#### Token effect
Submitted text is retained under the agent loop's normal session-history and compaction rules. Headers, cards, Markdown rendering, status lines, plans, and help text add no tokens.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Interactive user-question answers
**What the model sees**: When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels or `custom` text. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`.
#### What the model sees
**Token effect**: Waiting and terminal overlays add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result.
When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels or `custom` text. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`.
#### Token effect
Waiting and terminal overlays add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work

View File

@@ -286,7 +286,7 @@ export class HeadlessTerminal implements Terminal {
return violations
}
/** Serialize terminal cells and metadata into a stable, reviewable golden. */
/** Serialize terminal cells and metadata into a stable, reviewable expected output. */
async snapshot(options: TerminalSnapshotOptions = {}): Promise<string> {
await this.flush()
const buffer = this.emulator.buffer.active

View File

@@ -52,7 +52,7 @@ async function checkpoint(
observedCheckpoints.add(name)
expect(terminal.themeViolations(), `${name} must remain theme-agnostic`).toEqual([])
const snapshot = await terminal.snapshot(options)
const path = join(SNAPSHOTS_DIR, `${name}.golden.txt`)
const path = join(SNAPSHOTS_DIR, `${name}.expected.txt`)
if (REFRESHING) {
await mkdir(SNAPSHOTS_DIR, { recursive: true })
await writeFile(path, snapshot)
@@ -493,7 +493,7 @@ describe('TUI terminal-state snapshots', () => {
afterAll(async () => {
expect([...observedCheckpoints].sort()).toEqual([...CHECKPOINTS].sort())
const files = (await readdir(SNAPSHOTS_DIR))
.filter(file => file.endsWith('.golden.txt'))
.filter(file => file.endsWith('.expected.txt'))
.sort()
expect(files).toEqual(CHECKPOINTS.map(name => `${name}.golden.txt`).sort())
expect(files).toEqual(CHECKPOINTS.map(name => `${name}.expected.txt`).sort())
})

View File

@@ -8,34 +8,50 @@ Answerers are `approval/request` waterfall listeners. Return an outcome to answe
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise.
The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP bridge is the shipped human answerer for calls it owns. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md) and [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP bridge is the shipped human answerer for calls it owns. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
## Model Experience
### System prompt and policy notice
**What the model sees**: Under `ask`, every agent request carries the ask-policy prompt section below. Under `never`, it carries the never-policy prompt section below. A policy switch injects exactly `The approval policy changed from "<old>" to "<new>" (changed by the user).` or `The approval policy changed from "<old>" to "<new>" (changed by the operator/config).` before the next step.
#### What the model sees
**Token effect**: Small fixed per-request cost, larger under `never`; a change notice is conditional and retained in history.
Under `ask`, every agent request carries the ask-policy prompt section below. Under `never`, it carries the never-policy prompt section below. A policy switch injects exactly `The approval policy changed from "<old>" to "<new>" (changed by the user).` or `The approval policy changed from "<old>" to "<new>" (changed by the operator/config).` before the next step.
#### Ask-policy prompt section
##### Ask-policy prompt section
```markdown
<!-- dsh-user-approval-policy:ask -->
```
#### Never-policy prompt section
##### Never-policy prompt section
```markdown
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
<!-- dsh-user-approval-policy:never -->
```
#### Token effect
Small fixed per-request cost, larger under `never`; a change notice is conditional and retained in history.
#### KV Cache effect
Prefix-stable while the approval policy is unchanged. An `ask`/`never` switch changes the system-prompt section and invalidates reuse from its first changed token; the accompanying notice is append-only.
### Tool outcome
**What the model sees**: `approval/asked` and `approval/decided` are log-only. The model sees only the asking consumer's eventual allowed, rejected, cancelled, or unavailable tool outcome; the human permission UI is not context.
#### What the model sees
**Token effect**: Zero duplicate audit tokens. A rejection may replace a normal tool result with a small retained error, while an allowance leaves the consumer's ordinary result.
`approval/asked` and `approval/decided` are log-only. The model sees only the asking consumer's eventual allowed, rejected, cancelled, or unavailable tool outcome; the human permission UI is not context.
#### Token effect
Zero duplicate audit tokens. A rejection may replace a normal tool result with a small retained error, while an allowance leaves the consumer's ordinary result.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work

View File

@@ -27,6 +27,10 @@ This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-
Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: no user-interaction provider is registered`, or `Error: <message>`. Waiting for the human adds no tokens.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **One provider per context** — there is no routing or fan-out to multiple UIs; a second registration throws `DUPLICATE_PROVIDER`, and with none registered `ask()` throws `NO_PROVIDER` rather than degrading.