Merge branch 'master' into feat/website-docs

This commit is contained in:
Tianyi Cui
2026-07-17 21:19:33 +08:00
256 changed files with 11038 additions and 2633 deletions
+1
View File
@@ -11,6 +11,7 @@ examples/*/*.jsonl
examples/*/.sessions/
coverage/
.doc-typecheck-*/
.node-next-types-*/
.humanize/
tmp/
.claude/commands/
+4
View File
@@ -62,6 +62,10 @@ pnpm run demo:cordis # self-referential demo: the agent modifies its own runt
pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY)
```
### Host sandbox failures
When required `gh`, `pnpm`, build, test, or generator commands fail because the agent sandbox blocks credentials, network, IPC, file watching, or nested `sandbox-exec`, retry unchanged with the narrowest host escalation before diagnosing authentication or project failure. Require sandbox evidence; never bypass genuine test failures or the product sandbox under test.
### Run the CI gates locally before marking a PR ready
Run narrow checks during implementation and this CI-equivalent sequence before marking a PR ready. Fresh worktrees need `pnpm run build` before publint and NodeNext inspect `lib/`:
+1 -1
View File
@@ -35,7 +35,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service
| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry + generic `task_*` control tools |
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration |
| `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred logical-corpus and exact-event reads |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred logical-corpus exact reads and relationship traces |
## Event
+20 -6
View File
@@ -24,8 +24,11 @@ flowchart LR
svc_sessionPersistence["ctx.sessionPersistence<br/>Durable session persistence seam"]
pkg_session_persistence_jsonl["session-persistence-jsonl"]
pkg_session_persistence_sqlite["session-persistence-sqlite"]
pkg_tool_bash["tool-bash"]
pkg_hooks_claude["hooks-claude"]
pkg_hooks_codex["hooks-codex"]
pkg_acp["acp"]
svc_sessionQuery["ctx.sessionQuery<br/>Exact session-history reads"]
svc_sessionQuery["ctx.sessionQuery<br/>Exact session-history reads and traces"]
pkg_system_prompt["system-prompt"]
svc_systemPrompt["ctx.systemPrompt<br/>System prompt assembly registry"]
pkg_tools["tools"]
@@ -33,7 +36,6 @@ flowchart LR
pkg_tool_web["tool-web"]
svc_tools["ctx.tools<br/>Tool registry and guarded execution pipeline"]
pkg_tool_ask_user["tool-ask-user"]
pkg_tool_bash["tool-bash"]
pkg_tool_cordis["tool-cordis"]
pkg_tool_skill["tool-skill"]
pkg_tool_subagent["tool-subagent"]
@@ -51,8 +53,7 @@ flowchart LR
svc_bash["ctx.bash<br/>Bash executor seam"]
pkg_bash_local["bash-local"]
pkg_bash_sandbox["bash-sandbox"]
pkg_hooks_claude["hooks-claude"]
pkg_hooks_codex["hooks-codex"]
svc_bashEnv["ctx.bashEnv<br/>Managed bash environment registry"]
pkg_sandbox["sandbox"]
svc_sandbox["ctx.sandbox<br/>Process-sandbox seam"]
pkg_sandbox_local["sandbox-local"]
@@ -84,6 +85,10 @@ flowchart LR
pkg_web_search_perplexity["web-search-perplexity"]
pkg_web_search_deepseek["web-search-deepseek"]
pkg_web_fetch_local["web-fetch-local"]
pkg_spill["spill"]
svc_spillStore["ctx.spillStore<br/>Spill storage seam"]
pkg_spill_local["spill-local"]
pkg_spill_policy["spill-policy"]
pkg_workflow["workflow"]
svc_workflows["ctx.workflows<br/>Workflow script engine"]
pkg_workflow_workerthread["workflow-workerthread"]
@@ -116,6 +121,8 @@ flowchart LR
pkg_session_query --> svc_sessionQuery
pkg_skill --> svc_skills
pkg_skill_local --> svc_skills
pkg_spill --> svc_spillStore
pkg_spill_local --> svc_spillStore
pkg_stdio_demo --> svc_userInteraction
pkg_subagent --> svc_subagents
pkg_subagent_acp --> svc_subagents
@@ -124,6 +131,7 @@ flowchart LR
pkg_subagent_spawn --> svc_subagents
pkg_system_prompt --> svc_systemPrompt
pkg_tasks --> svc_tasks
pkg_tool_bash --> svc_bashEnv
pkg_tools --> svc_tools
pkg_user_interaction --> svc_userInteraction
pkg_web --> svc_web
@@ -153,7 +161,10 @@ flowchart LR
svc_sandbox --> pkg_bash_sandbox
svc_sessionPersistence --> pkg_acp
svc_sessionPersistence --> pkg_agent_loop
svc_sessionPersistence --> pkg_hooks_claude
svc_sessionPersistence --> pkg_hooks_codex
svc_sessionPersistence --> pkg_session_query
svc_sessionPersistence --> pkg_tool_bash
svc_sessions --> pkg_agent
svc_sessions --> pkg_agent_loop
svc_sessions --> pkg_invariants
@@ -161,6 +172,7 @@ flowchart LR
svc_sessions --> pkg_session_query
svc_sessions --> pkg_subagent_inprocess
svc_skills --> pkg_tool_skill
svc_spillStore --> pkg_spill_policy
svc_subagents --> pkg_tool_subagent
svc_systemPrompt --> pkg_agent_loop
svc_systemPrompt --> pkg_tool_fs
@@ -191,8 +203,8 @@ flowchart LR
| --- | --- | --- | --- | --- | --- | --- |
| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. |
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces. |
| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. |
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
@@ -200,6 +212,7 @@ flowchart LR
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. |
| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. |
| `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. |
| `ctx.approval` | `seam` | `approval` | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools), [`tool-bash`](../packages/bash/tool-bash) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. |
| `ctx.permission` | `core` | [`permission`](../packages/ui/permission) | - | [`acp`](../packages/ui/acp) | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. |
@@ -209,6 +222,7 @@ flowchart LR
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-mock`](../packages/support/subagent-mock) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. |
| `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. |
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow) | - | One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents. |
Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard.
+89 -20
View File
@@ -48,6 +48,8 @@ export interface Config {
toolOrder?: string[]
/** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */
tools?: ToolsConfig
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
dshHome?: string
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
@@ -97,13 +99,14 @@ Source: [`packages/core/agent-loop/src/index.ts:322`](../packages/core/agent-loo
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
* plugin (the deployment's persona section and the explicit model-facing tool
* order), the `tools` object to the tool registry (its presentation `mode`),
* `skills` to the skill registry/local provider/tool consumer,
* `workspaceContext` to the workspace-context loader, and
* `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns.
* Owner schemas supply defaults for optional input; workspace context instead
* requires an explicit byte budget or `false` because it changes model-visible
* input. Producer opt-in stays producer-local: `toolBash` configures bash only;
* independently composed producers keep their own config.
* `dshHome` to bash environment and local skill discovery, `skills` to the
* skill registry/local provider/tool consumer, `workspaceContext` to the
* workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool
* plugins this bundle owns. Owner schemas supply defaults for optional input;
* workspace context instead requires an explicit byte budget or `false` because
* it changes model-visible input. Producer opt-in stays producer-local:
* `toolBash` configures bash only; independently composed producers keep their
* own config.
*/
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
@@ -114,6 +117,8 @@ export interface Config {
toolOrder?: SystemPromptConfig['toolOrder']
/** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
tools?: ToolsConfig
/** DeepSeek Harness home directory shared by shell context and local skill discovery. */
dshHome?: string
/** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
workspaceContext: workspaceContext.Config | false
/** Skill registry, local provider, and model-facing consumer config. */
@@ -137,7 +142,7 @@ export interface SkillConfig {
Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts)
Source: [`packages/examples/agent-spine-demo/src/index.ts:55`](../packages/examples/agent-spine-demo/src/index.ts)
Source: [`packages/examples/agent-spine-demo/src/index.ts:57`](../packages/examples/agent-spine-demo/src/index.ts)
## `@deepseek-ai/dsh-bash-local`
@@ -309,7 +314,7 @@ export interface Config {
}
```
Source: [`packages/hooks/hooks-claude/src/index.ts:43`](../packages/hooks/hooks-claude/src/index.ts)
Source: [`packages/hooks/hooks-claude/src/index.ts:44`](../packages/hooks/hooks-claude/src/index.ts)
## `@deepseek-ai/dsh-hooks-codex`
@@ -334,7 +339,7 @@ export interface Config {
}
```
Source: [`packages/hooks/hooks-codex/src/index.ts:41`](../packages/hooks/hooks-codex/src/index.ts)
Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts)
## `@deepseek-ai/dsh-jsonrpc`
@@ -595,7 +600,7 @@ export interface Config {
}
```
Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:23`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:24`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
## `@deepseek-ai/dsh-session-persistence-sqlite`
@@ -630,14 +635,14 @@ export interface Config {
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
```
Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:38`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:39`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
## `@deepseek-ai/dsh-session-query`
Requires: `sessions`
```ts config-catalog
/** Configuration for exact session-query reads. */
/** Configuration for exact session-query reads and traces. */
export interface Config {
/** Maximum accepted raw read context on either side. Defaults to 50. */
readWindowMax?: number
@@ -674,7 +679,41 @@ export interface Config {
}
```
Source: [`packages/skill/skill-local/src/index.ts:39`](../packages/skill/skill-local/src/index.ts)
Source: [`packages/skill/skill-local/src/index.ts:40`](../packages/skill/skill-local/src/index.ts)
## `@deepseek-ai/dsh-spill-local`
```ts config-catalog
/** Plugin config (all optional — `static Config` supplies the defaults). */
export interface Config {
/**
* Root directory for spill files. Omitted uses a lazily-created private
* (0700) per-process directory under the OS temp dir — the safe default for
* a local deployment. Set it to keep spill files under a known location.
*/
root?: string
}
```
Source: [`packages/spill/spill-local/src/index.ts:22`](../packages/spill/spill-local/src/index.ts)
## `@deepseek-ai/dsh-spill-policy`
Requires: `tools`
```ts config-catalog
/** Plugin config. */
export interface Config {
/**
* The model-facing context cap for a plain-text tool result, in UTF-8 bytes.
* Omitted disables the policy entirely (no-op). When set, a result larger than
* this is spilled and replaced with a preview derived from this same budget.
*/
maxInlineBytes?: number
}
```
Source: [`packages/spill/spill-policy/src/index.ts:45`](../packages/spill/spill-policy/src/index.ts)
## `@deepseek-ai/dsh-stdio`
@@ -714,6 +753,8 @@ export interface Config {
toolOrder?: string[]
/** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */
tools?: ToolsConfig
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
dshHome?: string
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
@@ -872,33 +913,35 @@ Source: [`packages/core/system-prompt/src/index.ts:143`](../packages/core/system
## `@deepseek-ai/dsh-time-context`
Requires: `systemPrompt`
Requires: `agents`
```ts config-catalog
/** Request-time clock formatting and refresh policy. Invalid values fail plugin load. */
/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
export interface Config {
/** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */
timeZone?: string
/** Maximum age of a reading within one turn, in milliseconds (default 60,000; `0` refreshes every step). */
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible pre-step attempt. */
refreshIntervalMs?: number
}
```
Source: [`packages/context/time-context/src/index.ts:22`](../packages/context/time-context/src/index.ts)
Source: [`packages/context/time-context/src/index.ts:20`](../packages/context/time-context/src/index.ts)
## `@deepseek-ai/dsh-tool-bash`
Requires: `tools` · `bash` · `systemPrompt`
```ts config-catalog
/** Configures whether the model may background commands. */
/** Configuration for the bash tool and its managed child environment. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
/** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
}
```
Source: [`packages/bash/tool-bash/src/index.ts:30`](../packages/bash/tool-bash/src/index.ts)
Source: [`packages/bash/tool-bash/src/index.ts:39`](../packages/bash/tool-bash/src/index.ts)
## `@deepseek-ai/dsh-tool-cordis`
@@ -938,6 +981,28 @@ export interface Config {
Source: [`packages/fs/tool-fs/src/index.ts:22`](../packages/fs/tool-fs/src/index.ts)
## `@deepseek-ai/dsh-tool-fs-search`
Requires: `tools` · `systemPrompt` · `bash`
```ts config-catalog
/** Plugin config (all optional — `Config` supplies the defaults). */
export interface Config {
/** Max paths one `glob` call retains inline; later paths go to the formatted spill file. */
globMaxResults?: number
/** Max flat matches one `grep` call retains inline; later matches go to the formatted spill file. */
grepMaxMatches?: number
/** Max bytes retained for one matched-line preview (the cut preserves UTF-8 boundaries). */
grepMaxLineBytes?: number
/** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */
rawOutputMaxBytes?: number
/** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */
timeoutMs?: number
}
```
Source: [`packages/fs/tool-fs-search/src/index.ts:59`](../packages/fs/tool-fs-search/src/index.ts)
## `@deepseek-ai/dsh-tool-skill`
Requires: `tools` · `skills`
@@ -1296,6 +1361,7 @@ Abstract service classes — a deployment loads a concrete implementation packag
- `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts))
- `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts))
- `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts))
- `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts))
- `@deepseek-ai/dsh-workflow` — abstract `WorkflowService` ([`packages/workflow/workflow/src/index.ts`](../packages/workflow/workflow/src/index.ts))
## Library packages (no plugin entry)
@@ -1304,13 +1370,16 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/create-sdk` ([`packages/sdk/create-sdk/src/index.ts`](../packages/sdk/create-sdk/src/index.ts))
- `@deepseek-ai/dsh-acp-snapshot` ([`packages/support/acp-snapshot/src/index.ts`](../packages/support/acp-snapshot/src/index.ts))
- `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts))
- `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts))
- `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
- `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts))
- `@deepseek-ai/dsh-home` ([`packages/util/home/src/index.ts`](../packages/util/home/src/index.ts))
- `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
- `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts))
- `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts))
- `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts))
- `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts))
- `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
- `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts))
- `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts))
+38 -5
View File
@@ -71,7 +71,21 @@ abstract start(spec: BashExecSpec): BashProcess
Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md)
Source: [`packages/bash/bash/src/index.ts:46`](../../packages/bash/bash/src/index.ts)
Source: [`packages/bash/bash/src/index.ts:49`](../../packages/bash/bash/src/index.ts)
## `ctx.bashEnv` — `BashEnvRegistry`
Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model bash call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal.
```ts cordis-catalog
register(contributor: BashEnvContributor): () => void
collect(execution: ToolExecution): DshEnvironment
list(): BashEnvVariableInfo[]
```
Types: [ToolExecution](../core-data-structures/tools.md)
Source: [`packages/bash/tool-bash/src/index.ts:102`](../../packages/bash/tool-bash/src/index.ts)
## `ctx.codeRuntime` — `CodeRuntime` (abstract seam)
@@ -163,6 +177,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts:111`](../../packages/sandbox/san
Durable append-only session storage. Implementations preserve contiguous, losslessly JSON-serializable events; append resolves only after durability, and load balances a complete interrupted tail without rewriting committed events.
```ts cordis-catalog
abstract locate(meta: SessionHeader): SessionLocation | undefined
abstract create(meta: SessionHeader): Promise<void>
abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void>
abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
@@ -171,19 +186,21 @@ abstract list(): Promise<SessionHeader[]>
Types: [SessionEvent](../core-data-structures/core.md)
Source: [`packages/session-persistence/session-persistence/src/index.ts:30`](../../packages/session-persistence/session-persistence/src/index.ts)
Source: [`packages/session-persistence/session-persistence/src/index.ts:42`](../../packages/session-persistence/session-persistence/src/index.ts)
## `ctx.sessionQuery` — `SessionQueryService`
Live-preferred logical-corpus and exact-event read service.
Live-preferred logical-corpus exact-read and relationship-tracing service.
```ts cordis-catalog
listSessions(): Promise<SessionRecord[]>
async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]>
async traceSession(sessionId: SessionId): Promise<SessionLineageTrace>
async traceEvent(request: SessionEventTraceRequest): Promise<SessionEventTrace>
async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow>
```
Source: [`packages/session-query/session-query/src/index.ts:35`](../../packages/session-query/session-query/src/index.ts)
Source: [`packages/session-query/session-query/src/index.ts:38`](../../packages/session-query/session-query/src/index.ts)
## `ctx.sessions` — `SessionStore`
@@ -202,7 +219,7 @@ list(): Session[]
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
```
Source: [`packages/core/session/src/index.ts:580`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:540`](../../packages/core/session/src/index.ts)
## `ctx.skills` — `SkillService`
@@ -217,6 +234,22 @@ async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefiniti
Source: [`packages/skill/skill/src/index.ts:141`](../../packages/skill/skill/src/index.ts)
## `ctx.spillStore` — `SpillStore` (abstract seam)
Abstract spill storage service. Subclass, implement saveText, and load the subclass as a plugin — it registers as `ctx.spillStore` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
Semantics every implementation must honor:
- saveText persists the FULL `content` verbatim and returns an opaque locator, exact byte length, and model-facing retrieval guidance.
- Storage is scoped by the request's SaveTextSpill.owner session; the backend chooses a private (not world-readable) location and a collision-free name derived from — never equal to — the caller's `suggestedName`.
- `saveText` REJECTS on a real storage failure (permissions, ENOSPC, backend unavailable); the caller decides how to degrade (the spill policy treats a rejection as best-effort and keeps the inline result).
```ts cordis-catalog
abstract saveText(input: SaveTextSpill): Promise<SpillRef>
```
Source: [`packages/spill/spill/src/index.ts:45`](../../packages/spill/spill/src/index.ts)
## `ctx.subagents` — `SubagentService`
Named provider registry and capability-checked start surface.
+41 -8
View File
@@ -4,9 +4,21 @@ The bash execution seam is split across interface ([dsh-bash](../../packages/bas
Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts)
## Managed shell environment namespace
`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; executors remove inherited `DSH_*` names before merging the current snapshot.
```ts type-equiv
type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
```
```ts type-equiv
type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>
```
## Request vs. spec: the `resolve()` split
The seam separates the **model-/plugin-facing request** (optional `workdir`/`timeoutMs`, filled from config) from the **fully-resolved spec** the executor acts on (those fields required). The tool layer calls `ctx.bash.resolve(request)` between them — this is the repo's "explicit > implicit at package seams" rule made concrete: the reader of a `BashExecSpec` never wonders where the working directory came from.
The seam separates the **model-/plugin-facing request** (optional `workdir`/`timeoutMs`/`stdoutMaxBytes`, filled from config or request policy) from the **fully-resolved spec** the executor acts on (those fields required). The tool layer calls `ctx.bash.resolve(request)` between them — this is the repo's "explicit > implicit at package seams" rule made concrete: the reader of a `BashExecSpec` never wonders where the working directory or output budget came from.
```ts type-equiv
interface BashExecRequest {
@@ -15,6 +27,13 @@ interface BashExecRequest {
workdir?: string | undefined
/** Timeout override in milliseconds (implementations cap it). */
timeoutMs?: number | undefined
/**
* Foreground stdout capture budget in bytes. Absent uses the executor's
* default output cap. Trusted in-process consumers use this when they must
* parse complete stdout up to their own bounded limit; the model-facing bash
* tool does not expose it as a parameter.
*/
stdoutMaxBytes?: number | undefined
/** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | undefined
/**
@@ -26,15 +45,20 @@ interface BashExecRequest {
*/
stdin?: string | undefined
/**
* Extra environment entries for the command, merged AFTER the
* implementation's credential scrub (so an explicit entry here is honored even
* when its name matches the scrub pattern — the caller named a value it holds,
* not the harness's ambient secret). Set by in-process plugins (the hooks
* bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing
* bash tool does not expose it as a parameter (a model that needs an env var
* uses shell syntax like `FOO=bar cmd`).
* Ordinary environment entries for the command, merged after the credential
* scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it
* here. Set by in-process plugins (the hooks bridges set
* `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool
* does not expose it as a parameter.
*/
env?: Record<string, string> | undefined
/**
* Harness-owned `DSH_*` variables for this execution. Executors discard
* ambient `DSH_*` entries before merging this snapshot, so an unavailable
* current fact cannot inherit a stale value from the harness process, and
* reject non-`DSH_*` names supplied through this managed channel.
*/
dshEnv?: DshEnvironment | undefined
/**
* Explicit per-call sandbox-policy input, overriding the executor's
* configured default mode for THIS call. Never a silent default: a
@@ -57,6 +81,11 @@ interface BashExecSpec {
command: string
workdir: string
timeoutMs: number
/**
* Resolved foreground stdout capture budget in bytes. `run()` uses it for
* stdout; background tasks and stderr keep the executor's own output cap.
*/
stdoutMaxBytes: number
/** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | undefined
/**
@@ -73,6 +102,8 @@ interface BashExecSpec {
* config default, absent means "no extra env".
*/
env?: Record<string, string> | undefined
/** Managed `DSH_*` snapshot; implementations reject ordinary names. */
dshEnv?: DshEnvironment | undefined
/**
* The sandbox mode this call executes under, required-but-nullable so every
* resolved spec states its policy. A sandboxing executor's `resolve()` stamps
@@ -88,6 +119,8 @@ interface BashExecSpec {
`stdin` and `env` are trusted in-process plugin inputs and are not exposed by `dsh-tool-bash`. The local executor scrubs ambient credentials before merging explicit caller-supplied env. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
`stdoutMaxBytes` is also trusted-plugin-only. It lets a foreground consumer request complete stdout up to a bounded parser budget without changing stderr, background tasks, or the model-facing bash tool's ordinary output cap.
## Foreground runs: `BashRunResult`
The outcome of one completed (or killed) foreground run. Orthogonal outcomes are reported **independently** — a process can both time out AND exit 0 because it trapped the signal — so `timedOut`, `aborted`, `signal`, and `exitCode` are each their own field; a caller never reads a cut-short run as a clean success.
+2 -1
View File
@@ -19,7 +19,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context |
| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant |
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
| [session-query.md](session-query.md) | logical session/event records and bounded exact-event reads |
| [session-query.md](session-query.md) | logical records, bounded exact-event reads, and relationship traces |
| [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly |
| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, and the guarded execution pipeline |
| [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
@@ -32,6 +32,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface |
| [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split |
| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` |
| [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` |
| [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality |
> Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts.
+15 -2
View File
@@ -2,7 +2,7 @@
The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog.md).
The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list over the existing `SessionEvent`**no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md).
The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining locate/create/append/load/list over the existing `SessionEvent`**no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md).
## The flush checkpoint
@@ -12,6 +12,19 @@ The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06
A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `turn/end`. It does **not** truncate — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. Instead it closes the orphaned turn with a synthetic `turn/end { reason: { kind: 'interrupted' } }`, keeping the log balanced and the turn-enclosure invariant intact. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md](session.md#why-a-turn-ended-turnendreasonmap)).
## `SessionLocation` — optional per-session artifact target
`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns its absolute target path; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee.
```ts type-equiv
interface SessionLocation {
/** Backend-specific artifact kind, for example `jsonl`. */
readonly kind: string
/** Absolute path to this session's backend-owned artifact. */
readonly path: string
}
```
## `SessionHeader` — metadata beside the log
Per-session metadata travels **separately** from the event log: format version, cwd, lineage, and the seed boundary are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`.
@@ -80,7 +93,7 @@ Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resumi
## The backends
Both implement the same abstract `SessionPersistence` (create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic:
Both implement the same abstract `SessionPersistence` (locate/create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic:
- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path.
- **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync.
+52 -1
View File
@@ -1,6 +1,6 @@
# Session Query
Exact reads over the live-preferred logical session corpus. The [package contract](../../packages/session-query/session-query) owns source precedence, dynamic optional persistence, cloning, surface classification, bounded windows, and typed failures. Full-text search is a separate proposed SQLite phase.
Exact reads and relationship traces over the live-preferred logical session corpus. The [package contract](../../packages/session-query/session-query) owns source precedence, dynamic optional persistence, cloning, surface classification, bounded windows, tracing validation, and typed failures. Full-text search is a separate proposed SQLite package.
Source: [`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts)
@@ -30,6 +30,34 @@ export interface SessionEventRecord {
}
```
## Session lineage
`SessionLineageTrace` carries known parents in immediate-to-outward order and a forest of recursively nested direct descendants. The completeness discriminant makes a known root and a missing parent mutually exclusive.
```ts type-equiv
export interface SessionLineageNode {
session: SessionRecord
descendants: SessionLineageNode[]
}
```
```ts type-equiv
export type SessionLineageTrace = {
target: SessionRecord
ancestors: SessionRecord[]
descendants: SessionLineageNode[]
} & (
| {
complete: true
root: SessionRecord
}
| {
complete: false
unresolvedParentId: SessionId
}
)
```
## Bounded event reads
The request addresses one raw seq and optional neighboring counts. The result carries a `SessionHeader` rather than availability flags so a known live target can remain independent of persistence health.
@@ -53,6 +81,28 @@ export interface SessionEventWindow {
}
```
## Event relationships
Event traces distinguish positional surface replacement from logged provenance. Every seq list contains direct links except `replacementChain`, which follows immediate replacers from the target to the final positional replacement.
```ts type-equiv
export interface SessionEventTraceRequest {
sessionId: SessionId
seq: number
}
```
```ts type-equiv
export interface SessionEventTrace {
target: SessionEventRecord
replacedBy?: number
replacementChain: number[]
replacedEventSeqs: number[]
sourceEventSeqs: number[]
derivedEventSeqs: number[]
}
```
## Errors
The closed code union distinguishes request validation, missing targets, malformed surface logs, optional-backend failure, and contradictory source metadata.
@@ -61,6 +111,7 @@ The closed code union distinguishes request validation, missing targets, malform
export type SessionQueryErrorCode =
| 'SESSION_QUERY_EVENT_NOT_FOUND'
| 'SESSION_QUERY_INVALID_CONFIG'
| 'SESSION_QUERY_INVALID_LINEAGE'
| 'SESSION_QUERY_INVALID_SURFACE'
| 'SESSION_QUERY_INVALID_WINDOW'
| 'SESSION_QUERY_PERSISTENCE_FAILED'
+56
View File
@@ -0,0 +1,56 @@
# Spill Storage
The spill storage seam — a [capability seam](../rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md) that persists a tool's oversized text and returns a model-facing locator plus retrieval guidance, split across packages: interface ([dsh-spill](../../packages/spill/spill), `ctx.spillStore`), implementation ([dsh-spill-local](../../packages/spill/spill-local), private session-scoped files on the host filesystem), and consumer ([dsh-spill-policy](../../packages/spill/spill-policy), the `tools/post-execute` policy). Spill is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Preview mechanics stay in [dsh-retention](../../packages/util/retention); this seam only saves the final text the policy hands it.
Source: [`packages/spill/spill/src/types.ts`](../../packages/spill/spill/src/types.ts)
## The save request
`saveText` is the whole seam: persist `content` verbatim, return an opaque locator, a backend-supplied retrieval hint, and the exact byte count. The request carries the save-time storage namespace (`owner`), WHERE it came from (`source`, descriptive provenance for naming and inspection — not access control), and a `suggestedName` the backend may use as a naming hint (it is not a path).
```ts type-equiv
interface SaveTextSpill {
owner: SpillOwner
source: SpillSource
suggestedName: string
content: string
}
```
```ts type-equiv
interface SpillOwner {
sessionId: SessionId
}
```
`SpillOwner.sessionId` is the save-time storage namespace. Forked sessions inherit existing spill locators from the seeded log; those artifacts are not copied or re-owned, and spills produced after the fork use the child session id. A retention-period cleanup may expire old locators with other old session artifacts; the spill seam does not define a per-session cleanup policy.
```ts type-equiv
interface SpillSource {
toolName: string
callId: CallId
label: string
}
```
## The result
```ts type-equiv
interface SpillRef {
locator: SpillLocator
bytes: number
retrievalHint: string
}
```
`SpillLocator` is a [branded](core.md#branded-ids) model-facing handle returned by the backend. The local backend renders it as a filesystem path; a remote or database backend can render a URI, key, or command token. Consumers treat it as opaque and render it with `retrievalHint` instead of assuming `read` is always the right retrieval mechanism.
```ts type-equiv
type SpillLocator = Branded<'SpillLocator'>
```
## The service
`SpillStore` (`ctx.spillStore`, defined in [`packages/spill/spill/src/index.ts`](../../packages/spill/spill/src/index.ts)) is a one-method abstract service: `saveText(input) → Promise<SpillRef>`. It persists the FULL `content` and REJECTS on a real storage failure (permissions, ENOSPC, backend unavailable). The seam owns storage only: no retention policy, no tool-result replacement, no retrieval/search API.
The local backend ([dsh-spill-local](../../packages/spill/spill-local)) writes under `<root>/session-<hash>/<random>-<safeName>` — a configured or lazily-created private (0700) root, a `sha256(sessionId)` session subdir, and an exclusive owner-only (`open(path, 'wx', 0o600)`) write so a planted symlink cannot redirect it. Its `locator` is the local path and its `retrievalHint` tells the model to use `read` or `grep` on that path. The policy consumer ([dsh-spill-policy](../../packages/spill/spill-policy)) replaces an over-`maxInlineBytes` plain-text final result with a retention-library head/tail preview plus the spill reference, best-effort: a save failure keeps the original inline result rather than turning a successful call into an `isError`.
+2 -2
View File
@@ -10,7 +10,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:151`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:160`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:236`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
@@ -37,7 +37,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:98`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`workspace-context`](../packages/context/workspace-context) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:98`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:80`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
+49 -7
View File
@@ -9,7 +9,9 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri
flowchart TD
subgraph group_util["packages/util"]
pkg_brand["brand"]
pkg_home["home"]
pkg_paths["paths"]
pkg_retention["retention"]
pkg_timeout["timeout"]
end
subgraph group_llm["packages/llm"]
@@ -36,6 +38,7 @@ flowchart TD
pkg_fs_local["fs-local"]
pkg_fs_policy["fs-policy"]
pkg_tool_fs["tool-fs"]
pkg_tool_fs_search["tool-fs-search"]
end
subgraph group_skill["packages/skill"]
pkg_skill["skill"]
@@ -63,6 +66,11 @@ flowchart TD
pkg_web_search_exa["web-search-exa"]
pkg_web_search_perplexity["web-search-perplexity"]
end
subgraph group_spill["packages/spill"]
pkg_spill["spill"]
pkg_spill_local["spill-local"]
pkg_spill_policy["spill-policy"]
end
subgraph group_timeout["packages/timeout"]
pkg_timeout_policy["timeout-policy"]
end
@@ -87,6 +95,7 @@ flowchart TD
end
subgraph group_support["packages/support"]
pkg_acp_snapshot["acp-snapshot"]
pkg_agent_loop_testkit["agent-loop-testkit"]
pkg_invariants["invariants"]
pkg_llm_replay["llm-replay"]
pkg_loader_smoke["loader-smoke"]
@@ -164,6 +173,7 @@ flowchart TD
pkg_fs_local --> pkg_fs
pkg_fs_policy --> pkg_fs
pkg_skill_local --> pkg_fs
pkg_skill_local --> pkg_home
pkg_skill_local --> pkg_skill
pkg_compact --> pkg_llm
pkg_compact --> pkg_session
@@ -172,6 +182,9 @@ flowchart TD
pkg_web_search_deepseek --> pkg_web
pkg_web_search_exa --> pkg_web
pkg_web_search_perplexity --> pkg_web
pkg_spill --> pkg_brand
pkg_spill --> pkg_llm
pkg_spill --> pkg_session
pkg_session_persistence --> pkg_session
pkg_llm_replay --> pkg_llm
pkg_llm_replay --> pkg_session
@@ -183,6 +196,7 @@ flowchart TD
pkg_compact_basic --> pkg_compact
pkg_compact_basic --> pkg_llm
pkg_compact_basic --> pkg_session
pkg_spill_local --> pkg_spill
pkg_hook_protocol --> pkg_bash
pkg_hook_protocol --> pkg_session
pkg_session_persistence_jsonl --> pkg_session
@@ -205,7 +219,6 @@ flowchart TD
pkg_user_interaction --> pkg_agent
pkg_user_interaction --> pkg_llm
pkg_time_context --> pkg_agent
pkg_time_context --> pkg_system_prompt
pkg_tasks --> pkg_agent
pkg_tasks --> pkg_brand
pkg_tasks --> pkg_session
@@ -240,8 +253,10 @@ flowchart TD
pkg_agent_loop --> pkg_tools
pkg_tool_bash --> pkg_agent
pkg_tool_bash --> pkg_bash
pkg_tool_bash --> pkg_home
pkg_tool_bash --> pkg_llm
pkg_tool_bash --> pkg_sandbox
pkg_tool_bash --> pkg_session_persistence
pkg_tool_bash --> pkg_system_prompt
pkg_tool_bash --> pkg_tasks
pkg_tool_bash --> pkg_tools
@@ -251,6 +266,13 @@ flowchart TD
pkg_tool_fs --> pkg_session
pkg_tool_fs --> pkg_system_prompt
pkg_tool_fs --> pkg_tools
pkg_tool_fs_search --> pkg_bash
pkg_tool_fs_search --> pkg_llm
pkg_tool_fs_search --> pkg_retention
pkg_tool_fs_search --> pkg_session
pkg_tool_fs_search --> pkg_spill
pkg_tool_fs_search --> pkg_system_prompt
pkg_tool_fs_search --> pkg_tools
pkg_tool_skill --> pkg_agent
pkg_tool_skill --> pkg_llm
pkg_tool_skill --> pkg_skill
@@ -263,6 +285,11 @@ flowchart TD
pkg_tool_web --> pkg_system_prompt
pkg_tool_web --> pkg_tools
pkg_tool_web --> pkg_web
pkg_spill_policy --> pkg_llm
pkg_spill_policy --> pkg_retention
pkg_spill_policy --> pkg_session
pkg_spill_policy --> pkg_spill
pkg_spill_policy --> pkg_tools
pkg_timeout_policy --> pkg_llm
pkg_timeout_policy --> pkg_timeout
pkg_timeout_policy --> pkg_tools
@@ -275,7 +302,13 @@ flowchart TD
pkg_hooks_codex --> pkg_hook_protocol
pkg_hooks_codex --> pkg_llm
pkg_hooks_codex --> pkg_session
pkg_hooks_codex --> pkg_session_persistence
pkg_hooks_codex --> pkg_tools
pkg_agent_loop_testkit --> pkg_agent
pkg_agent_loop_testkit --> pkg_llm
pkg_agent_loop_testkit --> pkg_session
pkg_agent_loop_testkit --> pkg_system_prompt
pkg_agent_loop_testkit --> pkg_tools
pkg_acp --> pkg_agent
pkg_acp --> pkg_bash
pkg_acp --> pkg_llm
@@ -327,6 +360,7 @@ flowchart TD
pkg_hooks_claude --> pkg_hook_protocol
pkg_hooks_claude --> pkg_llm
pkg_hooks_claude --> pkg_session
pkg_hooks_claude --> pkg_session_persistence
pkg_hooks_claude --> pkg_subagent
pkg_hooks_claude --> pkg_tools
pkg_subagent_mock --> pkg_agent
@@ -339,6 +373,7 @@ flowchart TD
pkg_jsonrpc --> pkg_subagent
pkg_agent_spine_demo --> pkg_agent
pkg_agent_spine_demo --> pkg_agent_loop
pkg_agent_spine_demo --> pkg_home
pkg_agent_spine_demo --> pkg_invariants
pkg_agent_spine_demo --> pkg_llm
pkg_agent_spine_demo --> pkg_session
@@ -387,7 +422,9 @@ flowchart TD
| Package | Group | Depends on |
| --- | --- | --- |
| [`brand`](../packages/util/brand) | `util` | — |
| [`home`](../packages/util/home) | `util` | — |
| [`paths`](../packages/util/paths) | `util` | — |
| [`retention`](../packages/util/retention) | `util` | — |
| [`timeout`](../packages/util/timeout) | `util` | — |
| [`scope`](../packages/core/scope) | `core` | — |
| [`skill`](../packages/skill/skill) | `skill` | — |
@@ -412,17 +449,19 @@ flowchart TD
| [`bash`](../packages/bash/bash) | `bash` | [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) |
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) |
| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`skill`](../packages/skill/skill) |
| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`home`](../packages/util/home), [`skill`](../packages/skill/skill) |
| [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`web`](../packages/web/web) |
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`web`](../packages/web/web) |
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`web`](../packages/web/web) |
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) |
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) |
| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) |
| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
@@ -430,7 +469,7 @@ flowchart TD
| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
| [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent) |
| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) |
@@ -438,15 +477,18 @@ flowchart TD
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) |
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) |
| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) |
| [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) |
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) |
| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
@@ -457,10 +499,10 @@ flowchart TD
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) |
| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) |
| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
+6
View File
@@ -79,9 +79,13 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [Background subagent tasks](implemented/feature/2026-07-08-background-subagent-tasks.md) | 2026-07-08 |
| [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 |
| [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 |
| [Bash-backed grep and glob discovery tools](implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md) | 2026-07-09 |
| [Expose agent session identity and JSONL location to tools and hooks](implemented/feature/2026-07-10-agent-session-identity-and-log-location.md) | 2026-07-10 |
| [Exact session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 |
| [Configure subagent persona, tool visibility, and depth](implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) | 2026-07-12 |
| [Session query relationship tracing](implemented/feature/2026-07-13-session-query-tracing.md) | 2026-07-13 |
| [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 |
| [Durable per-step time context](implemented/feature/2026-07-16-durable-per-step-time-context.md) | 2026-07-16 |
### Simplification
@@ -146,8 +150,10 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [Every LLM request is reconstructable from the session log](implemented/architecture/2026-07-05-reconstructable-requests.md) | 2026-07-05 |
| [Subagent provider-lifecycle events — `subagent/provider-added` / `subagent/provider-removed`](implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md) | 2026-07-05 |
| [A shared timeout/deadline primitive, with hard-kill left to each capability](implemented/architecture/2026-07-06-timeout-deadline-library.md) | 2026-07-06 |
| [Tool result retention library](implemented/architecture/2026-07-06-tool-result-retention-library.md) | 2026-07-06 |
| [Tool-call timeout policy as a plugin](implemented/architecture/2026-07-07-tool-call-timeout-policy.md) | 2026-07-07 |
| [The agent is a registration scope](implemented/architecture/2026-07-08-agent-scope-contexts.md) | 2026-07-08 |
| [Tool output spill policy](implemented/architecture/2026-07-08-tool-output-spill-files.md) | 2026-07-08 |
| [Single-file executable SDK runtime distribution (single-exe)](implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) | 2026-07-10 |
| [Agent-scope runtime design and correctness](implemented/architecture/2026-07-12-agent-scope-runtime-design.md) | 2026-07-12 |
@@ -14,9 +14,9 @@ Add `stdin?: string` and `env?: Record<string, string>` to **both** `BashExecReq
Three deliberate choices:
1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly.
1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly. Harness-owned variables use the separate `dshEnv` channel from the [managed environment decision](../feature/2026-07-10-agent-session-identity-and-log-location.md), so ordinary `env` cannot replace them.
2. **`env` merges AFTER the credential scrub, so an explicit caller entry always wins** — even a credential-shaped name. This is correct because the scrub's job is narrow: stop the harness's *ambient* `process.env` credentials from leaking into a spawned command. A caller that explicitly sets a var has named a value it already holds (not the ambient secret), so the scrub is not a constraint on it. `childEnv(extra?)` layers `scrub(process.env)``ENV_OVERRIDES` (the model-friendly `TERM=dumb` etc.) → `extra`, last-wins.
2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision reserves `DSH_*`: ambient entries are removed, ordinary `env` cannot set them, and trusted `dshEnv` merges last. The complete order is `scrub(process.env, including DSH_*)``ENV_OVERRIDES` → ordinary `env``dshEnv`.
3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`.
@@ -0,0 +1,155 @@
# RFC: Tool result retention library
Status: implemented
## Problem
Several model-facing tools already bound the amount of context they return, but each one owns a different local mechanism and vocabulary: bash keeps a tail plus spill files, web search caps source lists, web fetch caps body content, and `glob` / `grep` discovery needs an inline first page while keeping exact omission metadata for the full result set. A single `truncate(text)` helper cannot cover those cases: item tools need item counts and grouping outside the primitive, while text tools need byte budgets and UTF-8-safe head/tail cuts.
The shared abstraction the tools need is **retention**, not generic collection. A caller feeds items or text chunks into a bounded object and later receives the retained content plus exact omission metadata. Tool-specific code still owns business semantics: file grouping, line numbering, exit codes, provider error states, spill files, and model-facing prose. The common library owns only the mechanical question "what did we keep, and what did we omit?"
## Decision
`@deepseek-ai/dsh-retention` lives under `packages/util/` (peer to `dsh-brand` and `dsh-timeout`) and owns bounded model-facing output. It is a library of pure classes and functions, **not** a Cordis service or plugin: it takes no `ctx`, registers nothing, holds no cross-call state, and emits no events. Tool packages import it directly when they need bounded output.
The library has two independent retainers:
- `ItemRetainer<T>` handles ordered logical units such as paths, grep matches, or search sources. It supports `head` retention only in v1, while keeping the retainer shape open to additional retention strategies later.
- `TextRetainer` handles byte-oriented text streams such as bash stdout/stderr or web response bodies. It supports `head`, `tail`, and `headTail` retention while preserving UTF-8 boundaries at `finish()`.
Both retainers return a small `PushDecision` after each `push()` so callers can tell whether that unit/chunk was fully retained and whether the accumulated result is now truncated. Omission counts are exact because callers keep feeding every observed item/chunk.
```ts ignore-check
/**
* How much content the retainer omitted.
*
* `unknown` is reserved for callers that omit without a count; the retainers
* themselves return `none` or `exact`.
*/
type Omitted =
| { kind: 'none' }
| { kind: 'exact'; count: number }
| { kind: 'unknown' }
interface PushDecision {
kept: boolean
truncated: boolean
}
/**
* Final result for ordered logical units.
*/
interface RetainedItems<T> {
items: T[]
truncated: boolean
seen: number
kept: number
omitted: Omitted
}
/**
* Final result for text streams.
*
* The returned `text` is safe to send to a formatter; the retainer does not add
* tool-specific headers, exit markers, XML tags, or recovery instructions.
*/
interface RetainedText {
text: string
truncated: boolean
omittedBytes: Omitted
}
```
### Strategies
Item retention supports a head window. Text retention supports head, tail, and headTail byte windows.
```ts ignore-check
type ItemRetentionStrategy =
| {
/** Keep the first `maxItems` units. Use for `glob`, `grep`, and web sources. */
kind: 'head'
maxItems: number
}
type TextRetentionStrategy =
| {
/** Keep the first `maxBytes` bytes. */
kind: 'head'
maxBytes: number
}
| {
/** Keep the final `maxBytes` bytes. Requires reading to the end. */
kind: 'tail'
maxBytes: number
}
| {
/** Keep a stable prefix and suffix, omitting the middle. Requires reading to the end. */
kind: 'headTail'
headBytes: number
tailBytes: number
}
```
### Tool mapping
`read` is intentionally outside the v1 retention library. Its `read-render` helper owns a file-specific pagination contract: `offset` / `limit`, line numbers, `totalLines`, offset-out-of-range errors, per-line preview truncation, and a selected-output byte cap that can stop scanning mid-window. That is a line-window renderer, not a generic retention primitive. It may share future neutral notice helpers, but it should not pass its already-selected window through `ItemRetainer`.
`FsGlobEntry` and `FlatGrepMatch` below are the intended discovery-tool item shapes, not existing retention-library exports. `FsGlobEntry` is one backend-derived path, and `FlatGrepMatch` is one ungrouped grep match before the backend groups retained matches by file.
`glob` uses `ItemRetainer<FsGlobEntry>` with `{ kind: 'head', maxItems: globMaxResults }` after collecting the full sorted path list. The tool keeps the retained first page inline and may save the full list through the spill seam. Path mapping, skipped candidates, and `incomplete` stay outside the retainer.
`grep` uses `ItemRetainer<FlatGrepMatch>` with `{ kind: 'head', maxItems: grepMaxMatches }` before grouping. The executor parses ripgrep output, maps paths, applies per-line preview truncation, and pushes flat matches. After `finish()`, the tool groups retained matches by file and can save the full match list through the spill seam when the inline result is capped. Grouping is not part of the retainer because the cap is total matches, not files; per-match preview truncation and `incomplete` are also separate from result-level retention.
`bash` can use `TextRetainer` with `tail` or `headTail` and reads to process completion. The bash executor still owns spill files, exit status, signal, timeout, and background-task behavior; the retention helper only replaces ad hoc in-memory head/tail accounting where that behavior is desired. Long-running task ownership remains orthogonal to the [generic long-running tool runtime](2026-06-20-generic-long-running-tool-runtime.md).
`web_fetch` can use `TextRetainer` with `head` or `headTail`, or keep provider-owned body caps when the provider must read and decode internally. Either way, the fetch result's `truncated` remains a provider/tool fact, and the library only supplies retained text and omission metadata.
`web_search` can use `ItemRetainer<WebSearchSource>` with `head`. Current providers often return an array, so this is post-hoc but still standardizes notices.
### Notices
The library exposes a neutral notice shape and a tiny formatter hook, but tools provide the user-facing words. A grep footer says "Narrow the pattern, path, or include"; a web fetch footer says "Fetch a more specific URL or section"; bash may point to a spill file. The retainer cannot know those recovery actions.
```ts ignore-check
interface RetentionNotice {
scope: string
strategy: 'head' | 'tail' | 'headTail'
unit: 'items' | 'bytes' | 'chars' | 'lines'
limit: number | { head: number; tail: number }
kept: number
omitted: Omitted
}
const formatGrepNotice = (notice: RetentionNotice): string =>
formatRetentionNotice(
notice,
({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`,
)
```
The formatter hook is deliberately small: a tool turns a `RetentionNotice` into its own footer text. The helper may standardize omission wording, but it does not own recovery guidance.
`truncated` means the retainer omitted otherwise-available content because of a budget. It does not mean the upstream was incomplete. Tools keep separate fields for permission failures, skipped binary files, provider partial failures, unreadable candidates, invalid UTF-8, and any other "could not inspect" condition.
## Consequences
**What shipped.** `@deepseek-ai/dsh-retention` exports `ItemRetainer`, `TextRetainer`, the result types (`RetainedItems`, `RetainedText`), the strategy types (`ItemRetentionStrategy`, `TextRetentionStrategy`), `Omitted`, `PushDecision`, `RetentionNotice`, and the neutral notice helpers `describeOmitted` / `formatRetentionNotice` — with no dependency on Cordis or any tool package. Unit tests cover item-head retention with exact omission counts, text-head retention, text-tail retention, head-tail byte retention, zero budgets, UTF-8 boundary handling (2-, 3-, and 4-byte codepoints and invalid lead bytes at each cut), and unknown omission wording.
**What is documented but not yet migrated.** `glob`, `grep`, `bash`, `web_fetch`, and `web_search` have their mappings documented in the [package README](../../../../packages/util/retention/README.md), but not every tool has been migrated onto the library in this change; migration is deliberately separate follow-up work. `read` is documented as intentionally out of scope: its `read-render` line-window contract (`offset`/`limit`, `totalLines`, offset-range errors, per-line preview truncation, a byte cap over the selected window) is not generic retention, and one `Omitted` count cannot represent both sides of a line window.
**Boundaries the library holds.** `truncated` means the retainer omitted otherwise-available content because of a budget; it never means the upstream was incomplete. Tool-specific states — `incomplete`, permission failures, provider partial failures, binary skips, bash spill-path recovery, invalid UTF-8 — stay in tool-domain fields, outside the retainer. When a future change migrates a tool, that package's README and tests must prove the model-facing result text is unchanged except for deliberate notice wording.
**Tradeoffs accepted.** The v1 surface deliberately supports only item `head` retention and text `head` / `tail` / `headTail`; windows, grouped budgets, sort-aware caps, and upstream-stop control wait until a second consumer proves the need. Text retention counts bytes for process/body safety, leaving character- and line-level preview budgets as separate tool-owned concerns.
## Alternatives considered
**Post-hoc `truncate(text)` only.** Rejected: it matches Codex's history/tool-output truncation use case but loses item counts, grouping boundaries, UTF-8-safe byte windows, and exact omission metadata.
**One generic `Collector<T>` with pluggable callbacks.** Rejected for v1: it hides the two important resource modes. Logical item retention counts items; text retention counts bytes and preserves UTF-8 boundaries. Separate `ItemRetainer` and `TextRetainer` names make that difference explicit while keeping the API small.
**Put `read` windowing behind `ItemRetainer`.** Rejected for v1: `read` is the only current window consumer, and its semantics are file pagination rather than generic retention. A single `Omitted` count cannot represent both sides of a line window, and `read` also carries `totalLines`, offset-range errors, per-line preview truncation, and a byte cap over selected output. Keeping `read-render` tool-owned avoids growing the shared library around one special case.
**Make truncation part of `ToolExecutionResult`.** Rejected: the tool registry would have to understand tool-specific recovery guidance, grouping, line numbering, exit status, and provider semantics. Retention is a library used before a tool returns `ContentBlock[]`; the model-facing result remains tool-owned.
**Expose limits in every model-facing tool schema.** Rejected as the default: Claude Code's grep exposes `head_limit` / `offset`, but this harness keeps routine budgets as deployment config unless the model genuinely needs pagination control. A future read-like continuation field can be added per tool; it does not belong in the shared retention primitive.
@@ -0,0 +1,189 @@
# RFC: Tool output spill policy
Status: implemented
## Problem
Tool outputs need bounded model-facing previews, but some oversized results are still useful later. A fetched page body or a verbose tool response should not consume the next model request in full, but the model should be able to inspect the complete formatted result later with existing file-reading tools.
Before this change the behavior was uneven. `dsh-bash-local` already writes complete stdout/stderr streams to private temp spill files when its in-memory tail overflows, but ordinary text tool results were returned inline unless the tool hand-rolled its own cap. The [tool result retention library](./2026-07-06-tool-result-retention-library.md) owns preview mechanics, but it does not own storage or an execution-pipeline policy that applies those mechanics to final tool results.
The shape matches the timeout policy design: a tool author normally returns the text result, and a policy plugin enforces the deployment's default context budget. Tool-specific early spill remains possible later for outputs that do not survive to the final `ToolExecutionResult`; the first cut proves the default final-result path.
## Decision
A thin spill storage seam plus a default spill policy plugin, in a new `packages/spill/` group:
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-spill` | Interface: `ctx.spillStore`, vocabulary types, no storage implementation. |
| `@deepseek-ai/dsh-spill-local` | Local backend: private, session-scoped file storage on the host filesystem. |
| `@deepseek-ai/dsh-spill-policy` | Tool-result policy plugin: wraps final text results after dispatch and replaces oversized results with a retained preview plus a spill locator. |
There is no dedicated model-facing consumer package. The consumer is the existing `ctx.tools` execution pipeline: `dsh-spill-policy` consumes final tool results through the `tools/post-execute` waterfall, and the model follows the backend-supplied retrieval hint for the returned locator.
### Spill seam
The storage seam is minimal: save text and return a locator plus retrieval hint.
```ts ignore-check
interface SpillStore {
saveText(input: SaveTextSpill): Promise<SpillRef>
}
interface SpillSource {
toolName: string
callId: CallId
label: string
}
interface SaveTextSpill {
owner: { sessionId: SessionId }
source: SpillSource
suggestedName: string
content: string
}
type SpillLocator = Branded<'SpillLocator'>
interface SpillRef {
locator: SpillLocator
bytes: number
retrievalHint: string
}
```
`SpillLocator` is a [branded](../../../../packages/util/brand) model-facing handle returned by the backend. The local backend renders it as a filesystem path; a remote or database backend can render a URI, key, or command token. Consumers treat it as opaque and render it with `retrievalHint` instead of assuming `read` is always the right retrieval mechanism. `SpillOwner.sessionId` is the save-time storage namespace: forked sessions inherit existing spill locators from the seeded log without copying or re-owning them, and new spills after the fork use the child session id. A retention-period cleanup may expire old locators with other old session artifacts; the spill seam does not define a per-session cleanup policy.
`dsh-spill-local` owns only storage details: session-scoped directory selection, safe names, path-traversal protection, the write, and returning `{ locator, bytes, retrievalHint }`. It does not own retention policy, tool-result replacement, search, or file inspection. Files land at `<root>/session-<hash>/<random>-<safeName>`, where `root` is a configured path or a lazily-created private (0700) per-process temp dir, the session subdir is a short `sha256(sessionId)` prefix, and the leaf is a random hex prefix plus the caller's `suggestedName` sanitized to one path segment (mirrors the JSONL backend's `encodeSegment`). The write is `open(path, 'wx', 0o600)` — exclusive and owner-only, so a planted symlink cannot redirect it. The locator is the path, and the retrieval hint tells the model it can use `read` or `grep` on that path.
### Spill policy
`dsh-spill-policy` is a `tools/post-execute` result transformer with one configuration knob:
```ts ignore-check
interface Config {
/** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */
maxInlineBytes?: number
}
```
When `maxInlineBytes` is omitted the plugin registers nothing (a true no-op). When set, it applies a default policy to final plain-text tool results:
1. Let the tool run normally, delegating via `next()` so a downstream listener settles the result first.
2. Flatten the accepted final `ContentBlock[]` only when it is entirely plain text; a result with any non-text block is left untouched.
3. If its UTF-8 byte size is at or below `maxInlineBytes`, leave it unchanged.
4. If it is larger, call `ctx.spillStore.saveText()` with the full final text.
5. Replace the model-facing result with a retained head/tail preview plus the spill reference.
The preview is an implementation default owned by the policy: a head/tail split of `maxInlineBytes` via the retention library's `TextRetainer`. Future config can expose preview sizing only after a second deployment needs it.
The replacement text is intentionally generic because the policy only knows the final formatted tool result, not the tool's internal resource:
```text
<retained preview>
(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
```
If `ctx.spillStore.saveText()` fails (permissions, ENOSPC, backend unavailable), or the call has no session owner, or no backend is loaded, the plugin logs the reason and returns the original result unchanged. Spill failure never turns a successful tool call into an `isError` result or hides the inline result.
The policy skips `read` to avoid a circular `read -> spill file -> read again` loop. Additional opt-out configuration is deferred until a real second tool needs it.
## Showcase: web_fetch
`web_fetch` is the first showcase because it returns a naturally large text result and needs no tool-specific spill code. The tool is ordinary:
```ts ignore-check
ctx.tools.register(defineTool({
name: 'web_fetch',
async execute(args, exec) {
const result = await ctx.web.fetch({ url: args.url }, exec.signal ? { signal: exec.signal } : undefined)
return [{ type: 'text', text: formatFetchOutput(result) }]
},
}))
```
With `dsh-spill-policy` configured, a large formatted fetch result is automatically retained and spilled. A deployment demonstrates the behavior by setting the provider resource cap higher than the policy cap:
```yaml
- id: web-fetch-local
name: '@deepseek-ai/dsh-web-fetch-local'
config:
maxBodyChars: 500000
- id: spill-local
name: '@deepseek-ai/dsh-spill-local'
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: 50000
```
This separation is important. `web-fetch-local` still owns resource caps (`maxResponseBytes`, `maxBodyChars`) to protect network, memory, and decoding work. `spill-policy` owns only the model-facing context cap after the result already exists. If the provider already returned `truncated: true`, the spill file contains the full formatted result the tool returned, not the full original webpage; the policy does not claim otherwise.
## Relationship to retention and early spill
Retention is separate from spill storage:
- `@deepseek-ai/dsh-retention` owns preview mechanics (`TextRetainer`, `ItemRetainer`, and omitted metadata).
- `@deepseek-ai/dsh-spill` owns saving final text and returning a locator plus retrieval hint.
- `@deepseek-ai/dsh-spill-policy` applies the default final-result policy in the tool pipeline, composing the two.
The final-result policy cannot replace tool-owned early spill. Some useful content is not present in final `ToolExecutionResult.content`:
- `bash` final output is already a tail plus a temp spill path; the complete stdout/stderr streams live in executor files.
- `subagent` final output is the child final answer, not the child rollout.
- Future tools may produce runtime artifacts that are never represented by their final `ToolExecutionResult.content`.
Those cases can consume `ctx.spillStore` directly in later work. They are not part of the first showcase.
## Non-goals
- No new model-facing `artifact_read` or `artifact_search` tool in v1.
- No per-tool retention configuration in v1.
- No model-facing timeout/truncation arguments.
- No migration of `read` output into spill files.
- No replacement for provider/resource caps such as `web-fetch-local.maxBodyChars`.
- No bash temp-file normalization or subagent rollout capture in the first cut.
## Deferred
- `saveFile()` / `linkOrCopy` for existing executor spill files, needed for bash normalization.
- Tool-owned spill for subagent rollouts (`await run.result`, read in-process child session before `run.dispose()`, save JSONL).
- Per-tool opt-out or per-tool policy declarations if the built-in `read` skip is insufficient.
- Remote or database storage backends for ACP or remote environments where a local path is not meaningful.
- Cleanup and retention policy for old spill files, likely tied to session cleanup.
## Testing
- `dsh-spill` unit tests pin the seam contract: registration as `ctx.spillStore`, one-implementation-per-context, and disposal release.
- `dsh-spill-local` unit tests cover `saveText`, `encodeSegment` sanitization (separators/tilde/whole-segment dots/empty), the session-hash directory, owner-only permissions, distinct paths per save, the configured/private root, and a storage-failure rejection.
- `dsh-spill-policy` unit tests drive real tools through `ctx.tools.execute`: disabled-mode no-op, oversized-text replacement, small/non-text passthrough, `read` skip, best-effort fallback (save failure / no backend / no owner), and downstream-composition (bounding a replaced result, preserving `additionalContexts`).
- `dsh-tool-web` integration drives `web_fetch` through `ctx.tools.execute` with the real `spill-local` backend + policy, proving the model-facing text changes only by the deliberate spill notice while the spill file holds the full formatted result.
- The `coding-agent` example loads `spill-local` + `spill-policy`, so its keyless Loader smoke exercises the real load path (the namespace-plugin export shape + `inject`).
## Consequences
The default policy only sees final formatted text. It cannot preserve provider-internal content that was already capped or runtime artifacts that were never part of the result. This is acceptable for the first cut because the showcase is final-result spill, not early spill; tool-owned early spill remains deferred work.
Returning real paths from the local backend keeps v1 simple and matches proven agent-tool behavior, while the seam itself only promises an opaque locator plus retrieval hint so remote backends can return non-file locators.
The local-backend value proposition depends on the existing `read`/`grep` tools being able to inspect the returned local path, even when the spill directory is outside the session cwd. That holds today because the filesystem policy records observations and write guards but does not confine reads to the workspace. A future workspace-confinement policy must either allow local spill paths explicitly or use a non-file spill backend whose retrieval hint points at a supported reader.
**Snapshot gap.** No ACP snapshot scenario covers the transcript-visible `web_fetch` spill notice yet. The ACP snapshot harness replays keyless and cannot hit the live web, and a `web_fetch` spill requires a real over-cap HTTP body; a deterministic scenario would need a seeded loopback fetch target the replay tree does not currently wire (the examples do not load `tool-web` at all). The behavior is covered instead by the `dsh-tool-web` integration test against a loopback server. Closing the gap is follow-up work: wire `tool-web` + a seeded fetch target into the ACP example, then record a `web-fetch-spill` scenario.
The policy can become too large if it starts owning tool-specific semantics. It stays narrow: plain-text final results only. Tool-owned early spill remains future work.
## Alternatives considered
**Require each tool to opt in with a retention declaration.** Rejected for v1: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single `maxInlineBytes` deployment knob is enough to prove the shape.
**Make `tool-results` a broad tool-result platform.** Rejected: a broad package name invites retention policy, result replacement, preview wording, search, and early spill into one seam. The shared storage part is smaller: save text and return a locator plus retrieval hint.
**Use `ctx.fs.writeText` or the model-facing `write` tool.** Rejected: workspace filesystem writes carry project-file semantics, write/edit policy, observation state, and user-facing side effects. Spill files are runtime artifacts, not model-authored workspace edits. The existing `read` tool may inspect them later, but creation belongs to the runtime spill seam.
**Let `web-fetch-local` fetch without caps and rely on spill-policy.** Rejected: spill-policy runs after the final tool result exists and cannot protect network, memory, or decoding resources. Provider resource caps stay mandatory.
**Merge retention into spill.** Rejected: retention and spill have different responsibilities. `TextRetainer`/`ItemRetainer` decide what preview is kept and what was omitted; spill storage only saves the final text the policy asks it to save.
@@ -12,8 +12,8 @@ The framing that shapes the whole design: **a bridge is a compatibility adapter,
Two independent plugins in the `packages/hooks/` group, each a function/namespace plugin (`name`/`inject`/`Config`/`apply`, NO default export — see [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)) injecting only `bash`:
- **`dsh-hooks-claude`** — the CC dialect. Seven of Claude Code's current hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, and `SubagentStop`. Owns CC-shaped per-event stdin payloads (a base of `session_id`/`cwd`/`hook_event_name` plus per-event fields), `CLAUDE_PROJECT_DIR` plus `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. A CC hook's stdin carries a **trailing newline**.
- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. It uses an always-regex matcher, Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no Codex plugin-env injection or config-time placeholder substitution, and no pre-tool approval or rewrite path. A tool call's payload carries the real `tool_name` in the bridge's reduced `tool_input: { command }` shape.
- **`dsh-hooks-claude`** — the CC dialect. Seven of Claude Code's current hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, and `SubagentStop`. Owns CC-shaped per-event stdin payloads (a base of `session_id`/`transcript_path`/`cwd`/`hook_event_name` plus per-event fields), `CLAUDE_PROJECT_DIR` plus `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. `transcript_path` is the persistence locator result or `''`; stdin carries a **trailing newline**.
- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. It uses an always-regex matcher, Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no Codex plugin-env injection or config-time placeholder substitution, and no pre-tool approval or rewrite path. `transcript_path` is the same locator result or `null`; tool payloads carry the real `tool_name` in the reduced `tool_input: { command }` shape.
### Outcome → Decision mapping
@@ -0,0 +1,166 @@
# RFC: Bash-backed grep and glob discovery tools
Status: implemented
## Problem
The harness needs model-facing `glob` and `grep` tools, but making them `ctx.fs` provider methods turns a local product convenience into a universal filesystem backend contract. Local workspace discovery is naturally a process-backed `rg` workflow; remote or virtual filesystem backends may expose their own search API, may not share a local `ripgrep` view, or may not support discovery at all. The v1 should not require every filesystem backend to implement search before the file read/write/edit seam has proven that need.
Search output also has two distinct budgets. The tool needs enough raw `rg` output to compute a stable logical result, but the model should receive only a bounded preview plus a recovery path when the formatted result is larger than the inline budget. The generic spill policy only sees the final tool result, so it cannot recover matches that a search tool already omitted. Search therefore needs tool-owned retention and best-effort formatted-result spill.
## Decision
`glob` and `grep` are model-facing tools in `@deepseek-ai/dsh-tool-fs-search`, backed by the bash seam, not by new `ctx.fs` provider methods. The package registers model-facing filesystem discovery tools, but execution uses `ctx.bash.resolve(request)` followed by `ctx.bash.run(spec)` with fixed `rg` command templates assembled by the tool. The tool layer owns schemas, argument validation, shell quoting, result parsing, result formatting, retention, formatted-result spill handoff, and timeout declaration. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution across local, sandboxed, or remote bash implementations.
The tools do not use `ctx.bash.start()` and do not create model-visible background tasks. They run as ordinary foreground tools from the agent loop's perspective: the tool call returns only after the `rg` command exits, times out, is aborted, or fails. `defineTool({ timeoutMs })` declares the cooperative tool-call budget, `@deepseek-ai/dsh-timeout-policy` enforces it through `exec.signal`, and the tool forwards that signal into the bash request before `resolve()` / `run()`. The bash backend's own timeout remains a second safety cap; whichever aborts first wins.
The tools align `path` with Claude Code's search tools while binding resolution to the bash workdir, not to `ctx.fs`. The tool derives the bash request workdir from `exec.agent?.session.header.cwd`, mirroring `dsh-tool-bash` and `dsh-tool-fs`; when no session cwd exists, it omits `request.workdir` so the bash implementation applies its configured cwd or process cwd through `resolve()`. For `grep`, `path` is an optional ripgrep target and may be a file or directory; omitted means the resolved bash workdir. For `glob`, `path` is an optional directory search root; omitted means the resolved bash workdir. Relative `path` values resolve against that workdir. Returned paths are displayed relative to the resolved bash workdir when possible and are intended to be follow-up-readable only in co-located deployments where the bash workdir and filesystem `read` root are the same workspace. v1 documents that deployment requirement but does not perform runtime cross-service validation. Remote or virtual filesystem search is deferred until there is a shared workspace/root contract or a provider-specific search backend.
The package does not inject `fs`. It injects `tools`, `systemPrompt`, and `bash`; it deliberately reads `spillStore` with `ctx.get('spillStore')` instead of static inject because formatted-result spill is optional. Existing `@deepseek-ai/dsh-tool-fs` deployments that only want `read` / `write` / `edit` do not need to load bash.
### Package shape
The v1 package stays small. Inside `@deepseek-ai/dsh-tool-fs-search`, the source layout is:
```text
src/index.ts
src/glob.ts
src/grep.ts
src/search-core.ts
src/shell-quote.ts
```
`glob.ts` and `grep.ts` own their parameter validation, command construction, result parsing, formatting, and registration. `shell-quote.ts` is one shared helper because shell quoting is the safety boundary both tools must use; `search-core.ts` is the other (an implementation-time amendment to the original four-file plan): the `SEARCH_*` error vocabulary, the bash-run + raw-output acquisition, the formatted-spill handoff, and workdir-relative display are byte-identical between the two tools, and duplicating that delicate plumbing per tool is exactly the missed extraction the symmetry convention flags. Command builders must not hand-roll quoting or concatenate unquoted model-controlled values into the shell command.
### Schemas and config
`glob` exposes the small discovery shape:
```ts
interface GlobArgs {
pattern: string
path?: string
}
```
`grep` exposes the OpenCode-style minimal shape:
```ts
interface GrepArgs {
pattern: string
path?: string
include?: string
}
```
Routine budgets stay out of the model-facing schema. `@deepseek-ai/dsh-tool-fs-search` owns these defaulted, validated config fields:
| Field | Default | Role |
|---|---:|---|
| `globMaxResults` | `100` | Max paths retained inline; matches Claude Code's default `GlobTool` result limit. |
| `grepMaxMatches` | `250` | Max flat matches retained inline; matches Claude Code's default `GrepTool` `head_limit`. |
| `grepMaxLineBytes` | `2000` | Max bytes retained for one matched-line preview, applied with `TextRetainer({ kind: 'head', maxBytes: grepMaxLineBytes })`. |
| `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout the tool will parse; matches Claude Code's ripgrep raw buffer. |
| `timeoutMs` | `30000` | Tool-call timeout attached to both tool definitions and enforced by `@deepseek-ai/dsh-timeout-policy`. |
`globMaxResults` and `grepMaxMatches` use `ItemRetainer({ kind: 'head' })`. `grepMaxLineBytes` uses `TextRetainer({ kind: 'head', maxBytes: grepMaxLineBytes })` for each matched line so preview cuts preserve UTF-8 boundaries. This follows the [tool result retention library](../../implemented/architecture/2026-07-06-tool-result-retention-library.md) mapping for discovery items: collect the complete result, retain head items inline, and keep path mapping, grouping, and per-line preview outside the retainer. `grep` does not expose `case_insensitive`, `head_limit`, `offset`, `count`, multiline, context lines, output modes, or file type filters in v1. A model that needs surrounding context reads the matched file with `read`; a model that needs later results follows the returned spill locator's retrieval hint.
The Claude Code values are reference points for the two-layer budget, not model-facing schema precedent. Its dedicated search tools buffer raw ripgrep output up to 20 MB for internal processing, use a 20-second ripgrep timeout on non-WSL platforms (60 seconds on WSL), then apply search-specific caps before the model sees a result: `GrepTool` defaults to `head_limit = 250` and persists formatted results above 20,000 characters, while `GlobTool` defaults to 100 paths and persists formatted results above 100,000 characters. This RFC mirrors the raw-buffer and inline-count defaults, chooses a 30-second default search timeout, and uses this harness's `ctx.spillStore.saveText()` path for formatted-result recovery.
The `path` field follows the same split as Claude Code: `grep.path` is a file-or-directory ripgrep target, while `glob.path` is a directory search root. v1 does not expose a separate cwd/workdir argument on these tools.
`include` is one positive glob filter, not a list and not an exclude syntax. Reject comma-separated or negated include patterns up front with a structured argument error. Every model-controlled value used in a shell command, including `pattern`, `path`, and `include`, must pass through the package-private shell quoting helper.
### Execution
`glob` builds a fixed `rg --files` command rooted at the resolved directory search root (`path` when supplied, else the bash workdir): `rg --files --glob <pattern> --sort=modified --no-ignore --hidden`, plus VCS metadata excludes for `.git`, `.svn`, `.hg`, `.bzr`, `.jj`, and `.sl`. This aligns with Claude Code on hidden/ignored-file discovery and modified-time ordering while keeping VCS internals out of broad searches. The tool parses one path per line, maps results back to paths relative to the bash workdir when possible, pushes each path into `ItemRetainer({ kind: 'head', maxItems: globMaxResults })`, and formats the full sorted path list for a spill artifact when the retained result is capped.
`grep` builds a fixed line-oriented `rg --json` command against the supplied file/directory target (`path` when supplied, else the bash workdir) so file path, line number, and line text are parsed without colon-splitting ambiguity. It consumes `match` records, treats malformed JSON or malformed match records as `SEARCH_FAILED`, maps result paths relative to the bash workdir when possible, applies per-line preview retention with `grepMaxLineBytes`, pushes each match into `ItemRetainer({ kind: 'head', maxItems: grepMaxMatches })`, then groups only the retained preview matches by file for inline output. The spill artifact stores the full formatted match list, not only the omitted tail, so the retrieval hint points at the same logical result the model saw.
Raw `rg` stdout is an internal transport detail. The tool requests `stdoutMaxBytes: rawOutputMaxBytes` through `ctx.bash.resolve()` and parses `stdout.text` only when the executor returns untruncated stdout within that cap. If stdout is larger than `rawOutputMaxBytes`, or the executor still returns `stdout.truncated`, the tool fails with a clear search error telling the model to narrow `pattern`, `path`, or `include`. The tool never exposes raw `rg` output or bash raw spill paths to the model.
Only stdout is a parse source. Stderr is diagnostic text for invalid patterns, missing `rg`, and search failures; if bash truncates stderr, the tool uses the retained stderr tail with a truncation note and does not read `stderr.spillPath`.
If `ctx.bash.run()` reports `aborted` because the tool timeout or caller cancellation fired, the tool returns a structured failure rather than pretending there were no matches. If bash reports its own timeout first, the tool likewise fails with a clear timeout message. Nonzero ripgrep exit semantics are tool-owned: exit 0 is success with matches, exit 1 is success with no matches, invalid pattern / missing `rg` / inaccessible search workdir are failures.
Search failures use a package-owned `HarnessError` subclass with `SEARCH_*` codes, not `FsErrorCode`, because these tools are not `ctx.fs` provider operations. The v1 vocabulary is `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, and `SEARCH_ABORTED`. Model argument validation failures such as missing required fields, blank strings, or unsupported negated/list `include` values remain ordinary tool argument errors.
### Formatted result spill
`ctx.spillStore` is optional and used only for model-facing formatted results. This is the first tool-owned spill call pattern in the codebase, and it is intentional because search retention is item-level policy: `globMaxResults` caps paths and `grepMaxMatches` caps matches while the tool still holds the complete logical result. The generic `dsh-spill-policy` caps final text bytes on `tools/post-execute`; by then a search tool would already have omitted later paths or matches, so the policy cannot recover them.
When a search produces more logical results than the inline cap and `ctx.spillStore` is present, the tool saves the complete formatted result with `saveText()`. The spill owner is the calling agent's session header id (`exec.agent?.session.header.id`); without that owner, the search keeps the inline result and reports that the complete result could not be saved. The spill source is the tool execution identity: `{ toolName: exec.name, callId: exec.callId, label: 'result' }`. The suggested filenames are `grep-results.txt` and `glob-results.txt`; the spill backend still treats them as hints, never paths.
When spill storage is absent, the call has no session owner, or saving fails, the tool still returns the inline page and a footer explaining that the complete result could not be saved. Search success must not turn into an `isError` result solely because formatted-result spill storage is unavailable.
The bash raw output stream and the formatted search spill artifact are different artifacts. Raw `rg` stdout is parsed only in memory within the requested bash stdout cap; the formatted spill artifact is the stable model-facing recovery locator produced by `ctx.spillStore.saveText()`.
### Result shape
A capped `glob` result with successful formatted spill returns the inline page and a spill notice:
```text
<first N paths>
(Showing N of M paths. Full sorted result stored at: /.../session-abc123/9f8e7d-glob-results.txt. Use read with offset/limit, or grep this path to search within it.)
```
A capped `grep` result with successful formatted spill returns grouped preview matches and a spill notice:
```text
Found N of M matches
<file>
Line 12: ...
(Full grep result stored at: /.../session-abc123/9f8e7d-grep-results.txt. Use read with offset/limit, or grep this path to search within it.)
```
If the complete logical result fits under the inline cap, no formatted spill artifact is created. If the complete logical result is too large but formatted spill is unavailable, the footer says that the result was capped and the complete result could not be saved. The `truncated` / omitted count is a budget fact, not an incomplete-search fact; timeout, invalid regex, missing `rg`, inaccessible workdirs, raw-output overflow, binary skips, and parse failures stay in tool-domain error or incomplete fields.
## Alternatives considered
**Put `glob` / `grep` on `ctx.fs`.** Rejected for v1: it forces every filesystem backend to grow a search API and makes local ripgrep behavior part of the provider seam. Search is useful product behavior, but it is not a universal text-storage primitive like `readText` or `writeText`.
**Directly spawn ripgrep from `dsh-fs-local`.** Rejected for this RFC's v1: direct spawn gives the cleanest argv boundary, stdout/stderr control, and early-stop control, but it duplicates process execution concerns that the bash seam already owns: environment scrubbing, process-group kill, timeout propagation, sandbox/remote executor substitution, and bounded output capture. It remains a reasonable optimization if bash-backed search proves too shell-string-sensitive or if foreground streaming becomes necessary.
**Use `ctx.bash.start()` for streaming early stop.** Rejected: `start()` creates model-visible background task semantics: task ids, owner tokens, `bash_output`, `bash_kill`, completion notifications, and no built-in timeout. `grep` needs a foreground tool result, not a background bash workflow. If streaming search becomes necessary, the right abstraction is a foreground streaming process handle on the bash/process seam, not borrowing the public background-task API.
**Expose bash raw spill paths to the model.** Rejected: a bash raw spill path contains raw `rg` stdout (`rg --json` records for grep), not the stable formatted search result. Search parses raw stdout only as an internal transport; model recovery uses a formatted result saved through `ctx.spillStore.saveText()`.
**Add `spillStore.saveFile()` for bash output normalization first.** Rejected for this RFC's v1: `saveFile()` would help a future bash normalization pass move existing executor spill files into session-scoped spill storage, but search only needs bounded in-memory raw `rg` stdout before producing the model-facing artifact. `saveText()` is sufficient for the formatted search result.
**Rely on the generic `dsh-spill-policy`.** Rejected: generic post-execute spill sees only the final tool result. If `grep` / `glob` return the first page inline, the generic policy cannot recover omitted results. The search tools must save the complete formatted result themselves before returning the bounded model-facing text.
**Expose Claude Code's full `GrepTool` schema.** Rejected for v1: `output_mode`, context flags, multiline, `head_limit`, `offset`, `case_insensitive`, and type filters make the model-facing surface into a ripgrep wrapper. This harness keeps routine budgets and continuation mechanics in deployment policy and spill artifacts.
**Keep early-stop search and skip formatted spill artifacts.** Rejected for this proposal: early stop is more efficient but gives the model no path to inspect later results. The chosen v1 optimizes result recoverability and implementation simplicity, with `timeoutMs`, `rawOutputMaxBytes`, bash backend caps, and formatted spill artifacts as safety backstops.
**Expand the bash seam with a raw-output reader first.** Rejected: a portable `readRawOutput(ref, maxBytes)` API would add reference lifetime, permission, and backend storage semantics. A per-run `stdoutMaxBytes` request is the narrower seam: search either receives complete stdout within `rawOutputMaxBytes` or fails clearly.
## Testing
- Tests prove an aborted `exec.signal` reaches the bash backend (same-reference spec assertion plus the `SEARCH_ABORTED` result), and cover command construction/quoting (malicious patterns, paths with spaces, leading-dash values, quotes, newlines, glob metacharacters — unit assertions plus a real `bash -c` round-trip for every hostile value), `grep.path` as file and directory targets, `glob.path` as a directory search root, invalid pattern handling, no matches, malformed `rg --json` output, matched-line preview truncation, raw-output overflow, timeout/abort, formatted spill success/failure, the package-owned `SEARCH_*` error codes, and the no-background-task invariant.
- The first-party tool-owned spill precedent is covered directly: spill backend present, spill backend absent, `saveText()` failure, and missing spill owner.
- The package has real Loader-path coverage for the namespace plugin export shape (`name`, `inject`, `Config`, and `apply`, with no default export).
- A real-executor integration suite (`dsh-bash-local` + a real `rg`) verifies the world: hostile patterns stay inert, per-session cwd resolution, VCS-metadata exclusion, modification-time ordering, and real ripgrep stderr classification. It self-skips where `rg` is not on PATH (a CI accommodation mirroring the keyless e2e skip); the fake-executor suite alone carries the per-file 100% coverage gate.
- Snapshot gap note for the transcript-visible spill notice: this landed with the gap note, not a snapshot. The snapshot tier replays the acp-agent tree, and adding the search plugin there changes the assembled system prompt — every golden would need re-recording with a real key, which the implementing environment did not hold. The spill notice's exact transcript text is pinned by unit tests (`formatGlobOutput`/`formatGrepOutput` and the through-the-registry spill tests); wiring the plugin into the acp-agent tree plus a `test:snapshot:record` pass is the follow-up for the next key-holding session.
## Consequences
- `glob` and `grep` are model-facing tools in `@deepseek-ai/dsh-tool-fs-search`, not `ctx.fs` provider methods and not part of the existing `@deepseek-ai/dsh-tool-fs` root plugin. The package injects `tools`, `systemPrompt`, and `bash`; it does not inject `fs`, and `ctx.spillStore` stays optional via `ctx.get('spillStore')`.
- The schemas are exactly `glob(pattern, path?)` and `grep(pattern, path?, include?)`; search caps and timeout are defaulted, validated Config fields (`globMaxResults`, `grepMaxMatches`, `grepMaxLineBytes`, `rawOutputMaxBytes`, `timeoutMs`).
- The tools execute through `ctx.bash.resolve(request)``ctx.bash.run(spec)`, forward `exec.signal`, never call `ctx.bash.start()`, and never expose a bash task id. The bash request workdir comes from `exec.agent?.session.header.cwd` when available; the resolved `spec.workdir` drives execution and relative-path display.
- The tools request `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam, parse only untruncated stdout within that cap, and treat over-cap or still-truncated raw output as a clear search failure; raw `rg` output is never exposed to the model.
- Oversized complete formatted results are saved through `ctx.spillStore.saveText()` when available while inline results stay bounded; spill failure, a missing backend, or a missing owner preserves the inline result and reports the unsaved remainder — never an `isError`.
- The package README, the generated config catalog, and exported JSDoc document the Config fields and `SEARCH_*` codes; the coding-agent example ships the tools (the acp-agent tree waits on the snapshot re-record above); the fs group README records the co-located bash/filesystem deployment requirement.
## Risks
Full-run `grep` can be slower than an early-stop search on broad patterns. The v1 accepts that cost for simpler implementation and complete-result recovery, bounded by tool timeout, bash timeout, `rawOutputMaxBytes`, and output caps. If this proves too slow, the direct-ripgrep or foreground-streaming alternatives remain available.
Shell command construction is the sharpest safety edge. Because `ctx.bash` accepts a command string rather than an argv vector, the implementation must centralize shell quoting and test malicious patterns, paths with spaces, leading-dash patterns, quotes, newlines, and glob metacharacters.
The v1 assumes a co-located bash/filesystem deployment. If bash searches one workspace and the `read` tool resolves paths against another, returned paths may not be follow-up-readable. The package documents this requirement but does not verify it at runtime.
Spill locators are backend-owned. The current local backend returns local filesystem paths and works in deployments where `read`/`grep` can open those files; remote or workspace-confined deployments can use a backend whose locator and retrieval hint point at a supported retrieval mechanism.
@@ -0,0 +1,85 @@
# RFC: Expose agent session identity and JSONL location to tools and hooks
Status: implemented
## Problem
An agent can identify its workspace through `session.header.cwd`, but a model using bash cannot reliably identify the session that owns the call or the durable transcript that records it. Searching `./.sessions` guesses deployment config and JSONL layout; custom roots, alternate persistence backends, resume, forks, and concurrent parent/child agents make that guess unreliable. Hooks have the same need for transcript location, while future plugins may need to expose other harness-owned environment facts to shell commands.
The boundary must preserve two properties: the owner of a fact decides how to resolve it, and every child receives a per-execution snapshot rather than process-global mutable state. In particular, a nested harness must not leak its ambient `DSH_*` values into a child whose current agent, persistence backend, or configuration differs.
## Decision
Extend the [`SessionPersistence`](../../implemented/architecture/2026-06-14-session-persistence.md) seam with a synchronous, side-effect-free location query:
```ts
import type { SessionHeader } from '@deepseek-ai/dsh-session'
interface SessionLocation {
readonly kind: string
readonly path: string
}
interface SessionPersistence {
locate(meta: SessionHeader): SessionLocation | undefined
}
```
`path` is an absolute local path to the backend's dedicated log for `meta`; `kind` identifies the representation. JSONL returns `{ kind: 'jsonl', path }` using its resolved root and path helpers. SQLite and any backend without an honest local per-session artifact return `undefined`. The query creates and flushes nothing, so it can report a lazy target path before that file exists.
The model-facing bash package owns a `ctx.bashEnv` registry. A contributor declares its stable name, every `DSH_*` key it may return, a description for each key, and `resolve(execution: ToolExecution)`. Duplicate contributor names, duplicate key ownership, reserved keys, malformed declarations, undeclared runtime output, and non-string output fail loudly. Registration is a Cordis effect and is removed with the contributing plugin fiber. `list()` exposes declarations without running resolvers, keeping the environment surface enumerable for diagnostics and future prompt/UI consumers.
The registry rebuilds a trusted overlay for every foreground and background bash `ToolExecution`:
- `DSH_HOME` is always the absolute configured Harness home. The standalone [`@deepseek-ai/dsh-home`](../../../../packages/util/home/README.md) utility owns its precedence: explicit `dshHome`, then ambient `$DSH_HOME`, then `~/.dsh`.
- `DSH_SHELL=1` is always present and identifies a model bash child managed by DeepSeek Harness.
- `DSH_SESSION_ID` is present when the execution has an agent and equals `agent.session.header.id`.
- The built-in persistence translator contributes `DSH_SESSION_JSONL` only when `ctx.sessionPersistence.locate(header)` returns `kind: 'jsonl'`.
Session persistence remains the fact owner: JSONL does not depend on tool-bash or register shell variables itself, and hooks continue to consume `locate()` directly. Tool-bash is the translation layer from the persistence fact into a shell convention. Other plugins that need shell-visible facts depend on the registry and register their own keys; they do not modify `process.env`.
The bash seam exports `DSH_ENV_PREFIX` as the single namespace source and derives `DshEnvironmentKey` from its `typeof`. Tool-bash derives built-in names and model guidance from that constant, while executors use it for filtering and channel validation. The seam carries the managed overlay separately as `BashExecRequest.dshEnv` / `BashExecSpec.dshEnv`. Ordinary `env` remains the general in-process plugin surface used by hooks, but cannot contain managed keys; symmetrically, `dshEnv` cannot contain ordinary keys. The local executor rejects either wrong channel before spawn, removes every inherited ambient managed key, applies its ordinary scrub/terminal environment/explicit `env`, and finally merges the trusted `dshEnv` snapshot. This guarantees that a missing value means absent now rather than inherited from an outer or previous harness. The model-facing tool still ignores model-supplied `env`/`stdin` arguments.
The bash tool description teaches only the durable convention: current harness environment facts are available through managed `$DSH_*` variables and may be inspected when needed. It does not enumerate persistence-specific keys or add a permanent system-prompt section. Tool schemas are already logged in request headers and tool output is logged as `tool/result`, so no new session event is required.
The [Claude Code and Codex hook bridges](../../implemented/feature/2026-06-30-hook-bridges.md) resolve transcript location from the same persistence seam when constructing payloads. Codex uses `transcript_path: string | null`; Claude Code preserves its string field and falls back to `''`. Hook lookup neither materializes nor flushes a session.
## Peer product findings
Peer products separate stable identity from physical storage. Codex injects stable `CODEX_THREAD_ID` into spawned shells while recorder and hook surfaces own transcript paths. Claude Code supplies `session_id` and `transcript_path` as structured hook/status input. OpenCode carries identity in structured tool context; Kimi Code expands a session placeholder; Reasonix keeps the active session path on its controller. The portable rule is to inject identity at the invocation boundary, let storage resolve location, and never use a process-global current-session variable in a concurrent harness.
## Lifecycle and persistence semantics
A fresh session receives its id before the first turn, so its first bash call can read `DSH_SESSION_ID` and a JSONL target. The JSONL file may still be absent until the first successful turn-end checkpoint, and during an open turn it contains only the last flushed prefix. `DSH_SESSION_JSONL` is a location hint, not an authorization credential or freshness guarantee.
Resume reuses the loaded header and therefore the same id and location. Fork and spawn create new session ids and locations. Parent and child calls resolve from their own `ToolExecution.agent`; each command receives an immutable snapshot even when calls overlap. A persistence service replacement affects later collections because the translator queries `ctx.get('sessionPersistence')` at execution time; the registry itself is effect-scoped and HMR-safe.
`dshHome` is session-independent deployment context. Agent-core resolves one value through `@deepseek-ai/dsh-home` and routes it to both tool-bash and local skill discovery; standalone consumers call the same resolver. If top-level `dshHome` and `skills.local.dshHome` are both supplied and resolve differently, composition fails instead of exposing contradictory homes. Persistence may change independently without freezing its facts into the session prefix.
## Testing
Unit coverage pins registry declaration validation, effect disposal, per-execution collection, the `dshHome` precedence, and the local executor's `DSH_*` scrub/rebuild order. Request-recording tests cover foreground/background snapshots, no-agent calls, absent/JSONL persistence, ignored model `env`, and parent/child isolation. JSONL/SQLite locator contract tests and both hook bridge suites pin available and unavailable transcript dialects.
A keyless full-loop integration drives the real agent loop, JSONL persistence, tool-bash, and bash-local on the first turn. The child prints `DSH_HOME`, `DSH_SHELL`, session id, JSONL target, and an inherited stale sentinel; the test verifies current values, absence of the stale variable, pre-flush file absence, and the eventual persisted header. Snapshot coverage pins the generic bash description in the recorded request header. No with-key test is required because the contract is deterministic local execution rather than model choice.
## Alternatives considered
**Only an id plus `find`.** Search cannot know a custom root or backend layout and races under multiple sessions.
**Only an absolute path.** A path can be unavailable, lazy, or representation-specific and is not stable session identity.
**Global `process.env`.** Concurrent agents would overwrite one another and nested harnesses would inherit stale current-session values.
**Put persistence instructions in the session prefix.** A session prefix is frozen while the active service can change across HMR or future backend switching; persistence-specific guidance would become stale.
**A typed waterfall event.** Listeners cannot declare ownership without running, and later listeners can silently overwrite keys. A registry detects key conflicts at registration and remains enumerable.
**Have each persistence backend register bash env directly.** That reverses the dependency from storage into one consumer and forces bash into deployments that do not use it. `locate()` is also still required by hooks.
**A model-facing `session_info` tool.** It adds schema and another call while bash already supplies the query surface; the registry generalizes to future environment facts without one tool per fact.
## Consequences
Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. JSONL-backed calls get an optional target path; non-file persistence omits it honestly. The complete `DSH_*` namespace inside these children is managed by the harness: ambient values are removed, current trusted values are re-added, and ordinary callers cannot use `env` to bypass ownership checks.
The namespace is discoverable but not secret. Paths can reveal configured roots, lazy targets can be absent or stale, and a command can override variables inside its own shell syntax. Consumers treat them as correlation and environment facts, verify transcript metadata when attribution matters, and rely on sandbox/filesystem policy rather than variable secrecy for authorization.
@@ -4,13 +4,13 @@ Status: implemented
## Problem
Session history exists in two places: current `SessionStore` objects and an optional persistence backend. Consumers that need exact inspection would otherwise duplicate live-versus-persisted precedence, persistence lifecycle handling, raw-event surface classification, and defensive cloning. Durable state can lag the live log between checkpoints, so persistence alone is not a truthful current source.
Session history exists in two places: current `SessionStore` objects and an optional persistence backend. Consumers that need exact inspection would otherwise duplicate live-versus-persisted precedence, persistence lifecycle handling, raw-event surface classification, relationship tracing, and defensive cloning. Durable state can lag the live log between checkpoints, so persistence alone is not a truthful current source.
Full-text search is related but materially larger. Designing provider registration, extraction, synchronization, invalidation, ranking, and cursor contracts before a real backend exists creates two speculative state machines: one in the interface service and another in the eventual database package.
## Decision
`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-read service over one logical corpus. It exposes `listSessions()`, `listEvents(sessionId)`, and bounded `readEvent(request)`. It does not expose filters, lineage or provenance traversals, text extractors, search requests, provider registration, or derived-index synchronization.
`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-inspection service over one logical corpus. It exposes `listSessions()`, `listEvents(sessionId)`, bounded `readEvent(request)`, `traceSession(sessionId)`, and `traceEvent(request)`. It does not expose filters, text extractors, search requests, provider registration, or derived-index synchronization. The separate [tracing decision](2026-07-13-session-query-tracing.md) owns lineage and event-relationship semantics.
The service observes the optional `ctx.sessionPersistence` binding dynamically but retains no persisted cache or invalidation listener. Each cross-corpus list asks the active backend for authoritative metadata, then overlays a fresh live-store list. Matching ids become one `SessionRecord`: the live header wins and `live`/`persisted` independently report source availability. Immutable header disagreement is `SESSION_QUERY_SOURCE_CONFLICT`.
@@ -18,13 +18,13 @@ An exact target read first checks the live store and snapshots the live header a
## Surface semantics
`dsh-session` exports `foldSurface(events)`, and `SurfaceManager` uses the same transition functions for its incremental cache. The fold returns detached current nodes and each replacement's actual removed seqs. `listEvents()` uses that result to classify every raw event as `current`, `shadowed`, or `log-only`, so inspection cannot disagree with model-history derivation about positional replacement semantics.
`dsh-session` exports `foldSurface(events)`, and `SurfaceManager` uses the same transition functions for its incremental cache. The fold returns detached current nodes and each replacement's actual removed seqs. `listEvents()` and `traceEvent()` use that result to classify every raw event, so inspection cannot disagree with model-history derivation about positional replacement semantics.
`readEvent()` returns the complete target plus raw neighbors by contiguous seq. `before` and `after` default to zero and are independently bounded by `readWindowMax`, default 50. The result carries a cloned `SessionHeader`, not a source-availability record, because determining a live target's persisted flag would violate the guarantee that live exact reads do not depend on persistence health.
## Security boundary
The service is context-wide trusted infrastructure, not an authorization layer. A future model-facing history tool or human UI applies explicit caller/session scope. This phase adds no model-facing tool and changes no transcript or snapshot surface.
The service is context-wide trusted infrastructure, not an authorization layer. A future model-facing history tool or human UI applies explicit caller/session scope. The service adds no model-facing tool and changes no transcript or snapshot surface.
## Alternatives considered
@@ -32,10 +32,9 @@ The service is context-wide trusted infrastructure, not an authorization layer.
- **Query only persistence** — rejected because checkpoints can lag the current live log.
- **Cache persisted metadata and listen for writes/removals** — rejected because exact reads can ask the authoritative sources directly, while cache invalidation adds lifecycle and concurrency state before scale requires it.
- **Define a provider-neutral search protocol now** — rejected because no provider consumes it. The first SQLite FTS package should own one reconciliation/transaction state machine; a smaller shared seam can be extracted later only when a second implementation proves the boundary.
- **Include lineage, provenance, and generic filters in phase one** — rejected because no current consumer requires them and canonical logs remain sufficient to add them with evidence later.
## Consequences
Phase one has one source-resolution state variable: the currently mounted persistence service. There are no provider queues, fingerprints, extractor registries, observation generations, or derived index updates. Exact reads remain usable in live-only deployments and deterministic when persistence is present.
The service has one source-resolution state variable: the currently mounted persistence service. There are no provider queues, fingerprints, extractor registries, observation generations, or derived index updates. Exact reads and event traces remain usable in live-only deployments and deterministic when persistence is present.
Cross-corpus listing and persisted exact reads perform backend I/O on each call. That is deliberate: correctness comes from current authoritative state, and scale-oriented search belongs to the phase-two database. Full-text search is unavailable until that package defines and implements its complete contract.
Cross-corpus listing, lineage tracing, and persisted event operations perform backend I/O on each call. That is deliberate: correctness comes from current authoritative state, and scale-oriented search belongs to the proposed database package. Full-text search is unavailable until that package defines and implements its complete contract.
@@ -0,0 +1,34 @@
# RFC: Session query relationship tracing
Status: implemented
## Problem
Session relationships are encoded across immutable headers, positional surface operations, and logged provenance arrays. A consumer reconstructing those relationships directly would need to duplicate corpus precedence, surface folding, malformed-log handling, deterministic lineage ordering, and cloning. Positional replacement and provenance are different graphs, so collapsing them into one generic edge type would also lose meaning.
## Decision
`ctx.sessionQuery` exposes `traceSession(sessionId)` and `traceEvent({ sessionId, seq })` alongside its exact reads. Both are one-shot views over the existing live-preferred corpus: session tracing consumes one complete corpus listing, while event tracing consumes one loaded logical log and one canonical surface fold. The service retains no lineage, reverse-index, or replacement state after a call.
`SessionLineageTrace` returns the target, known parents in immediate-to-outward order, and recursive descendant trees whose siblings sort by creation time and then session id. `complete: true` carries the known root; `complete: false` carries the first unresolved parent id. A cycle connected to the target fails with `SESSION_QUERY_INVALID_LINEAGE`.
`SessionEventTrace` keeps positional and provenance relationships separate. `replacedBy` is the immediate positional replacer, `replacementChain` follows replacers to the final node, and `replacedEventSeqs` lists the actual surface nodes directly removed by the target. `sourceEventSeqs` preserves direct logged source order, while `derivedEventSeqs` lists later direct reverse references in log order. Provenance is not expanded transitively.
## Validation boundary
Event tracing checks target existence before surface analysis. Both event listing and tracing then use `dsh-session`'s one-pass surface fold, which accepts or rejects the loaded log as a whole: event seqs are zero-based and contiguous, surface markers obey event-type eligibility, provenance belongs only to surface event types, present arrays are nonempty and duplicate-free, every source is an earlier seq, and every positional replacement names and cites all surface nodes it removes. Every contract failure uses `SESSION_QUERY_INVALID_SURFACE`; there is no weaker classification-only surface standard.
All returned records and arrays are detached. A known live event trace never consults persistence; persisted event traces preserve the exact-read list/load consistency check. Session lineage is necessarily a cross-corpus operation and therefore preserves cross-corpus persistence failure semantics.
## Alternatives considered
- **Expose standalone tracing helpers** — rejected because the source-precedence and detachment boundary belongs to `ctx.sessionQuery`; public helpers would invite callers to bypass it.
- **Combine replacement and provenance edges** — rejected because a positional replacement can shadow surface nodes while also citing non-surface construction inputs, and consumers need to distinguish those meanings.
- **Return transitive provenance closure** — rejected because it obscures logged direct evidence, increases result size, and lets one malformed distant edge alter otherwise local output.
- **Best-effort traces over malformed provenance** — rejected because a structurally plausible partial graph would look authoritative. Exact inspection fails loudly when the canonical relationship contract is broken.
## Consequences
Consumers receive deterministic relationship views without a cache or second corpus. Event tracing performs whole-log validation and allocation on each call, while lineage tracing lists the complete logical corpus on each call. Those costs keep the source of truth explicit and are separate from the content-bearing full-text-search and filtering API.
The feature has unit and service-level coverage but no snapshot or end-to-end fixture because it introduces no model-facing consumer, transcript change, or cross-process protocol.
@@ -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
2026-07-14-time-context-plugin.md: 105bf53550f087fdefb1e6fe0ec493f8628d3e18
2026-07-14-time-context-plugin.zh.md: 60e9004b1453e75e1bcd84870ad7f18d200a95d8
2026-07-14-time-context-plugin.md: aa24c6246718cfe0bb3ed63d0791cf890514d9fe
2026-07-14-time-context-plugin.zh.md: e939ff1d0cb250f3fa7a7db34b50d92760e3374d
@@ -6,6 +6,8 @@ English | [中文](2026-07-14-time-context-plugin.zh.md)
## Problem
The dynamic system-prompt storage and refresh decision in this record is superseded by [Durable per-step time context](2026-07-16-durable-per-step-time-context.md). The opt-in package, zoned formatting, and validation remain; the follow-up owns the current model-visible and durability contract.
An agent request has no live clock unless a deployment puts one in prompt text or gives the model a query tool. Static text becomes stale, while a tool call adds overhead to ordinary reasoning about dates, deadlines, or idle time. Without elapsed time, the model cannot distinguish an immediate follow-up from one sent hours after the preceding message.
Prompt assembly can derive both facts per step from durable session timestamps, and request-header logging can record the exact rendered value. Accumulating stale readings in conversation history or waking idle agents would violate the existing request lifecycle.
@@ -6,13 +6,15 @@ Status: implemented
## 问题
本记录中的动态系统提示词存储和刷新决策已由[持久的逐步骤时间上下文](2026-07-16-durable-per-step-time-context.md)取代。需要显式启用的包(package)、分区时间格式和校验仍然保留;后续 RFC 负责当前的模型可见与持久性契约。
如果部署方既未在提示词中提供时钟,也未给模型提供查询工具,agent(智能体)请求就无法获得实时准确的时间。静态文本会变得陈旧,而对于日期、截止时间或闲置时长等常规推理,调用工具会增加开销。缺少已经过去的时长时,模型无法区分紧接着发送的消息与上一条消息几小时后才发送的消息。
提示词组装流程可以在每个步骤中根据持久会话时间戳派生这两项信息,请求头日志则可以记录实际渲染的确切值。在会话历史中累积陈旧读数或唤醒空闲 agent 都会违反现有请求生命周期。
## 决策
`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 产品分组用于容纳既不定义工具、也不定义服务的有界请求上下文增强。`dsh-agent-spine-demo` 和仓库提供的示例都不会加载该 package;只有当 token 与信息披露成本可接受时,部署方才显式挂载它。
`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 产品分组用于容纳既不定义工具、也不定义服务的有界请求上下文增强。`dsh-agent-spine-demo` 和仓库提供的示例都不会加载该;只有当 token 与信息披露成本可接受时,部署方才显式挂载它。
该插件注册顺序值为 10 的全局系统提示词区段 `context:time`,位置在部署方角色设定之后、工具指导之前。对于活跃轮次,它会输出带数字 UTC 偏移和 IANA 时区、形似 ISO 的时间戳,以及从轮次开始前最后一条模型可见消息起算的紧凑整秒时长。未绑定 agent 或 agent 处于空闲状态时,该区段为空。
@@ -46,7 +48,7 @@ agent loop(智能体循环)会在发送前通过 `request/header` 和 `reque
- **省略配置时仍默认使用 UTC**——不予采纳,因为显式启用的时钟应跟随部署环境,除非运维方选择 UTC。需要 UTC 的部署仍可配置 `timeZone: UTC`
- **引入时区探测库**——不予采纳,因为 Node 的 `Intl` 运行时已经能够提供进程的 IANA 时区,而且额外依赖同样无法推断远程用户的时区。
- **在 `dsh-agent-spine-demo` 中挂载插件**——不予采纳,因为时区、信息披露、token 预算和新鲜度都属于部署策略。选择加入能保持默认上下文稳定。
- **将 package 放入 `core/`**——不予采纳,因为 `core/` 负责产品 API 主干,而该插件是没有服务键的可选叶节点。
- **将放入 `core/`**——不予采纳,因为 `core/` 负责产品 API 主干,而该插件是没有服务键的可选叶节点。
## 后果
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
2026-07-16-durable-per-step-time-context.md: 12d8191eb72b3fabd3164f13a77d9944631b906e
2026-07-16-durable-per-step-time-context.zh.md: 977ffb7276011ac9ae9b8a72a726bb23baba0a2d
@@ -0,0 +1,70 @@
# RFC: Durable per-step time context
Status: implemented
English | [中文](2026-07-16-durable-per-step-time-context.zh.md)
## Problem
A request-only clock can tell the model the current time, but replacing that value in the system prompt removes the evidence behind earlier time-sensitive reasoning. Multi-step turns need requests to retain the readings that shaped preceding steps. The request must remain reconstructable after restart, and automatic compaction must account for the same timing context the model receives.
A process-local refresh cache makes displayed time depend on state that cannot survive resume or be reconstructed from the durable session. Durable interval scheduling can reduce append frequency without introducing that hidden state.
## Decision
`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. It registers a prepended `agent/pre-step` listener and, when an injection is due, calls `agent.inject()` for a pre-step attempt whose signal is not already aborted. The injected `context/message` carries source `{ kind: 'plugin', plugin: 'time-context' }` and append surface metadata; a suppressed attempt appends nothing.
The listener records preparation context before a possible `step/start`. Its prepended registration runs before ordinary automatic compaction listeners, so pressure estimation and any resulting surface rewrite observe a newly appended reading. A later pre-step listener can cancel or fail the attempt before the step opens; the reading remains because the durable log is append-only and this plugin performs no rollback.
The optional `timeZone` config resolves the Node process's IANA zone once at plugin load when omitted; an explicit value is validated by `Intl.DateTimeFormat`. The timestamp includes the numeric UTC offset and resolved IANA zone.
The optional `refreshIntervalMs` config is manually validated at plugin load as a non-negative safe integer. Omission or `0` injects on every eligible preparation attempt. A positive value scans the raw session events for the most recent `context/message` with this plugin's source and injects when none exists, wall time moved backward, or the event is at least the configured age. The raw event timestamp governs even after compaction shadows the message, so scheduling persists across turns and process resume without a timer or process-local cache.
### Text and elapsed baselines
An injected first-step reading is:
```text
Time sampled while preparing turn <turn>, step 1: <timestamp>
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
```
The baseline is the latest preceding user, assistant, tool-result, context, or steering message. This includes the accepted prompt that opened an ordinary message turn. If no model-visible message exists, the duration is `unavailable`.
An injected later-step reading is:
```text
Time sampled while preparing turn <turn>, step <step>: <timestamp>
Elapsed since the preceding step context: <duration-or-unavailable>.
```
Their baseline is the durable event timestamp of the preceding time-context message in the same turn. If interval suppression leaves no earlier same-turn reading, the duration is `unavailable`. Duration formatting uses compact whole-second units and clamps backward wall-clock movement to zero. The explicit turn and step make every retained reading attributable to its historical preparation attempt after later turns append more context.
### Durability and request reconstruction
Each reading remains a normal surface node until compaction shadows it; positive interval scheduling never removes existing readings. A later request therefore sees the cumulative unshadowed readings that affected earlier preparation and steps, rather than a system-prompt value rewritten in place.
The plugin contributes nothing to system-prompt assembly. `request/header` and `request/header-delta` contain no time-context text; request reconstruction obtains the complete durable surface prefix at each `step/start`. Readings and requests need not map one-to-one because a failed preparation can leave a reading while interval suppression can prepare a request without appending one. The plugin depends on the agent registry for its lifecycle listener and does not require the system-prompt service at runtime.
## Testing
Unit and real-loop tests pin formatting, both elapsed baselines, interval omission and zero, threshold boundaries, cross-turn and per-session scheduling, backward-clock behavior, invalid config, resumed raw-event lookup after compaction, aborted-signal behavior, later-listener cancellation and failure, listener disposal, source and surface metadata, cumulative multi-step visibility, and absence from request headers. A keyless subprocess e2e boots the real Loader and stdio app, drives two turns, and verifies the persisted context events externally.
## Supersedes
This decision supersedes the dynamic system-prompt storage and refresh policy in [Optional time-context plugin](2026-07-14-time-context-plugin.md). It keeps the package location, opt-in deployment stance, timestamp formatting, process-zone default, and load-time validation. Durable history replaces the `context:time` prompt section, process-local refresh cache, and request-header deltas; `refreshIntervalMs` controls durable append frequency instead of prompt replacement.
## Alternatives considered
- **Keep the dynamic system-prompt section and process-local refresh cache** — rejected because replacement erases earlier readings, cache state is not replayable, and a frozen request envelope would make the value stale for an entire loop instance.
- **Replace the preceding context surface node** — rejected because replacement preserves the old node's position or shadows intervening conversation; neither represents when the new reading became visible.
- **Inject from a background timer** — rejected because idle time has no pending request to consume the value, and timer-driven injection would create durable turns solely to report time passing.
- **Expose time only through a tool** — rejected because ordinary temporal reasoning would require an avoidable tool round trip and would not guarantee a reading before every step.
- **Use `agent/session-prefix`** — rejected because one loop-instance prefix cannot represent distinct step timestamps and does not accumulate historically attributable readings.
## Consequences
- Omission or `0` records every eligible preparation attempt; a positive interval reduces append frequency and history growth while preserving durable scheduling across resume.
- Timing context remains append-only until compaction shadows older surface nodes, including a preparation reading left by a later cancellation or failure.
- The first-step duration normally measures from the prompt that opened the turn, while later-step durations measure model and tool processing since the preceding step context.
- An omitted `timeZone` still reflects the deployment process rather than a remote user, and elapsed time still uses durable harness append boundaries rather than client-origin timestamps.
@@ -0,0 +1,70 @@
# RFC: 持久的逐步骤时间上下文
Status: implemented
[English](2026-07-16-durable-per-step-time-context.md) | 中文
## 问题
仅存在于请求中的时钟可以告诉模型当前时间,但在系统提示词中替换这个值会移除先前时效性推理所依据的证据。在包含多个步骤的轮次中,请求需要保留影响先前步骤的读数。系统必须能在重启后重建请求,自动压缩(compaction)也必须核算模型实际收到的同一份时间上下文。
进程本地刷新缓存使显示的时间依赖无法在恢复后保留、也无法从持久会话重建的状态。持久的间隔调度可以减少追加频率,而不引入这种隐藏状态。
## 决策
`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。它注册一个前置的 `agent/pre-step` 监听器,并在需要注入时,为信号尚未取消的预步骤尝试调用 `agent.inject()`。注入的 `context/message` 携带来源 `{ kind: 'plugin', plugin: 'time-context' }` 和追加表层元数据;受间隔抑制的尝试不会追加任何内容。
监听器在可能出现的 `step/start` 之前记录准备上下文。它采用前置注册,因此先于普通自动压缩监听器运行,使压力估算和由此产生的表层重写都能观察到新追加的读数。后续预步骤监听器可能在步骤开启前取消尝试或使其失败;持久日志仅追加,且本插件不执行回滚,因此该读数会保留下来。
省略可选配置 `timeZone` 时,插件在加载时解析一次 Node 进程的 IANA 时区;显式值由 `Intl.DateTimeFormat` 校验。时间戳包含数字 UTC 偏移和解析后的 IANA 时区。
插件在加载时手动校验可选配置 `refreshIntervalMs`,其值必须为非负安全整数。省略或设为 `0` 时,每次符合条件的准备尝试都会注入。设为正数时,插件扫描原始会话事件,查找来源属于本插件的最新 `context/message`;不存在此类事件、系统挂钟向后移动,或该事件已达到配置时长时,插件执行注入。即使压缩已隐藏消息,调度仍以原始事件时间戳为准,因此该机制无需计时器或进程本地缓存,也能跨轮次和进程恢复持续生效。
### 文本与时长基线
第一个步骤的注入读数为:
```text
Time sampled while preparing turn <turn>, step 1: <timestamp>
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
```
基线是前一条用户消息、助手消息、工具结果、上下文消息或 steering(中途引导)消息。对于普通消息轮次,这包括开启轮次的已接受提示词。如果不存在模型可见消息,时长为 `unavailable`
后续步骤的注入读数为:
```text
Time sampled while preparing turn <turn>, step <step>: <timestamp>
Elapsed since the preceding step context: <duration-or-unavailable>.
```
其基线是同一轮次中上一条时间上下文消息的持久事件时间戳。如果间隔抑制导致同一轮次中没有更早的读数,时长为 `unavailable`。时长采用紧凑的整秒单位,并在系统挂钟向后移动时钳制为零。显式的轮次号和步骤号使每个保留的读数在后续轮次追加更多上下文后,仍可归属于对应的历史准备尝试。
### 持久性与请求重建
每个读数都作为普通表层节点保留,直至压缩将其隐藏;正数间隔调度绝不会移除已有读数。因此,后续请求会看到影响先前准备过程和步骤且尚未被隐藏的累计读数,而不是一个被原地改写的系统提示词值。
插件不向系统提示词组装贡献任何内容。`request/header``request/header-delta` 不包含时间上下文文本;请求重建从每个 `step/start` 取得完整的持久表层前缀。读数与请求无需一一对应,因为失败的准备过程可能留下读数,而间隔抑制也可能使请求准备过程不追加读数。插件通过 agent 注册表使用生命周期监听器,运行时不需要系统提示词服务。
## 测试
单元测试和真实 agent loop(智能体循环)测试固定格式化、两种时长基线、间隔省略和零值、阈值边界、跨轮次和各会话独立调度、挂钟后退行为、无效配置、压缩后基于恢复会话的原始事件查找、已取消信号行为、后续监听器取消和失败、监听器 dispose(资源释放)、来源与表层元数据、多步骤累计可见性,以及请求头中不存在时间上下文。无密钥子进程 e2e 测试通过真实 Loader 和 stdio 应用启动,驱动两个轮次,并从外部校验持久化的上下文事件。
## 取代的决策
本决策取代[可选时间上下文插件](2026-07-14-time-context-plugin.md)中的动态系统提示词存储和刷新策略。它保留包位置、选择加入式部署、时间戳格式、进程时区默认值和加载时校验。持久历史取代 `context:time` 提示词区段、进程本地刷新缓存和请求头增量;`refreshIntervalMs` 用于控制持久追加频率,而非提示词替换。
## 考虑过的替代方案
- **保留动态系统提示词区段和进程本地刷新缓存**——不予采纳,因为替换会抹去先前读数,缓存状态无法回放,而且冻结的请求内容集合会使该值在整个 agent loop 实例期间保持陈旧。
- **替换前一条上下文表层节点**——不予采纳,因为替换会保留旧节点的位置或隐藏中间的会话内容;两者都不能表达新读数何时开始可见。
- **通过后台计时器注入**——不予采纳,因为空闲期间没有待处理请求消费该值,而且计时器驱动的注入会仅为报告时间流逝而创建持久轮次。
- **只通过工具提供时间**——不予采纳,因为普通时间推理会产生本可避免的工具往返,也不能保证每个步骤之前都有读数。
- **使用 `agent/session-prefix`**——不予采纳,因为一个 loop 实例前缀无法表示不同的步骤时间戳,也不会累计具有历史归属的读数。
## 后果
- 省略 `refreshIntervalMs` 或设为 `0` 时,每次符合条件的准备尝试都会留下记录;正数间隔会减少追加频率和历史增长,同时使持久调度在恢复后继续生效。
- 时间上下文仅追加并保留到压缩隐藏旧表层节点为止,其中也包括后续取消或失败所留下的准备读数。
- 第一个步骤的时长通常从开启轮次的提示词起算,后续步骤的时长则反映自上一条步骤上下文以来的模型与工具处理时间。
- 省略 `timeZone` 时仍采用部署进程而非远程用户的时区,时长仍采用 harness 的持久追加边界而非客户端来源时间戳。
@@ -14,9 +14,9 @@ Flattening those members directly into `lefthook.yml` solves the local hook only
[lefthook.yml](../../../../lefthook.yml) keeps one pre-push job named `full check` and runs `pnpm run check:pre-push`. That package script delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), the same bounded scheduler CI uses.
The `pre-push` mode expands into leaf gates for the unit suite, snapshot suite, build, `hygiene` members, `doc-sync` members, and module-graph freshness. The leaf list keeps the same gate vocabulary as the package scripts, including RFC classification and RFC format, while the runner schedules independent checks concurrently and prints one timing/output block per gate.
The `pre-push` mode expands into leaf gates for the unit suite, snapshot suite, build, `hygiene` members, `doc-sync` members, and module-graph freshness. The leaf list keeps the same gate vocabulary as the package scripts, including RFC classification and RFC format, while the runner schedules independent checks with four active top-level workers by default; `DSH_GATE_CONCURRENCY` overrides that bound.
The build gate makes the hook self-contained from a clean worktree. `publint` and `verify-node-next-types` wait for that build output, while source-only gates continue in parallel.
The build gate makes the hook self-contained from a clean worktree. `publint`, `verify-node-next-types`, and the pre-push form of `doc-typecheck` wait for that build output, while source-only gates continue in parallel.
[scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers the package list from `packages/<group>/<pkg>` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block.
@@ -4,7 +4,7 @@ Status: proposed
## Problem
The exact-read `ctx.sessionQuery` service deliberately has no derived index. Large persisted histories need full-text search without scanning every event on every query, while current live sessions need an overlay newer than the last durability checkpoint. Search also needs concrete ranking, snippets, filters, pagination, cancellation, and rebuild behavior.
The exact-read `ctx.sessionQuery` service deliberately has no derived index. Large persisted histories need full-text search without scanning every event on every query, while current live sessions need an overlay newer than the last durability checkpoint. Search also needs concrete ranking, snippets, pagination, cancellation, and rebuild behavior.
Splitting those concerns across a speculative provider coordinator and a database implementation would create two coupled reconciliation state machines. The first real implementation should own the source observation, extraction, SQLite transaction, generation, and query as one lifecycle.
@@ -20,7 +20,7 @@ Persisted documents survive restarts. Live overrides are connection-local and sh
The implementation must define both cross-session and within-session scopes from executable use cases. Each searchable event is one document with session metadata, event metadata, surface classification, normalized semantic text, and a bounded plain-text snippet. Session results group by their strongest matching event; numeric backend scores remain private.
Filters compile to parameterized SQL before ranking. Query syntax is treated as data. Ordering includes stable tie fields. Opaque cursors bind to normalized request shape and the smallest relevant generation; unrelated session changes should not invalidate a within-session cursor. Cancellation must stop caller waiting and interrupt SQLite work where the runtime permits.
Search returns content-bearing result records rather than metadata-only headers. Chainable filters operate on that exact result shape and are designed and implemented with the search API instead of becoming a provider-specific pre-ranking contract. Query syntax is treated as data. Ordering includes stable tie fields. Opaque cursors bind to normalized request shape and the smallest relevant generation; unrelated session changes should not invalidate a within-session cursor. Cancellation must stop caller waiting and interrupt SQLite work where the runtime permits.
Tokenizer choice remains an implementation experiment. FTS5 trigram supports substring recall but rejects useful terms shorter than three characters and increases index size; the proposal must benchmark that tradeoff against the default Unicode tokenizer before making it contract.
@@ -41,7 +41,7 @@ Reconciliation may use stable fingerprints to avoid rewriting unchanged persiste
- Restart tests cover unchanged, new, changed, and deleted persisted sessions without rebuilding the whole index.
- Reopening preserves persisted rows and removes live rows; live rows shadow and then reveal their persisted base.
- Tests cover both search scopes, metadata filters, surface defaults, snippets, escaping, deterministic ties, pagination, scoped stale cursors, cancellation, dynamic persistence mount/unmount, and recovery after a failed transaction.
- Tests cover both search scopes, content-bearing results, chainable result filters, surface defaults, snippets, escaping, deterministic ties, pagination, scoped stale cursors, cancellation, dynamic persistence mount/unmount, and recovery after a failed transaction.
- A schema mismatch resets only the derived database.
- A keyless end-to-end test combines a real persistence backend with the real SQLite search package.
- The RFC is amended to the measured tokenizer and public API actually implemented before moving to `implemented/`.
+60 -1
View File
@@ -20,6 +20,7 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. |
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - |
| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. |
| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. |
@@ -126,7 +127,7 @@ Owned by the tool registry as a reserved transport outside filterable capability
### `bash`
Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.
Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.
```json
{
@@ -330,6 +331,64 @@ Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts
The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.
## `@deepseek-ai/dsh-tool-fs-search`
### `glob`
Find files whose paths match a glob pattern. Returns matching paths sorted by modification time, including hidden and ignored files (VCS metadata directories are excluded). Returns the first 100 paths inline; a capped result reports where the complete list was saved.
```json
{
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\")."
},
"path": {
"type": "string",
"description": "Directory to search in. Defaults to the session workspace; a relative path resolves against it."
}
},
"required": [
"pattern"
]
}
```
Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-search/src/index.ts)
### `grep`
Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.
```json
{
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regular expression to search for (ripgrep syntax)."
},
"path": {
"type": "string",
"description": "File or directory to search. Defaults to the session workspace; a relative path resolves against it."
},
"include": {
"type": "string",
"description": "One glob filter for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). Not a list; negation is not supported."
}
},
"required": [
"pattern"
]
}
```
Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-search/src/index.ts)
glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.
## `@deepseek-ai/dsh-tool-skill`
### `skill`
+1 -1
View File
@@ -7,7 +7,7 @@ pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env)
pnpm run demo:code-mode acp # the same server in Code Mode: one wire tool, run_code
```
The leaf config loads the ACP app, DeepSeek adapter, sandboxed bash, approval and permission services, model-facing tools, and repeat guard. The app bundles the agent spine, JSONL persistence, and bridge, creates agents on `session/new`, and keeps stdout logger-free. [`fs.cordis.yml`](fs.cordis.yml) adds the unconfined in-process filesystem stack for its dedicated scenarios; [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK. See [Code Mode](../../packages/core/tools/README.md#code-mode).
The leaf config loads the ACP app, DeepSeek adapter, sandboxed bash, approval and permission services, model-facing tools, and repeat guard. The app bundles the agent spine, JSONL persistence, and bridge, creates agents on `session/new`, and keeps stdout logger-free. [`fs.cordis.yml`](fs.cordis.yml) adds the unconfined in-process filesystem stack and local tool-result spill storage for its dedicated scenarios; [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK. See [Code Mode](../../packages/core/tools/README.md#code-mode).
## stdout is the protocol
@@ -17,5 +17,13 @@
name: '@deepseek-ai/dsh-fs-policy'
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
- id: spill-local
name: '@deepseek-ai/dsh-spill-local'
config:
root: !!js process.env.DSH_SNAPSHOT_SPILL_ROOT ?? './.spill'
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: 800
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
+8
View File
@@ -15,3 +15,11 @@
name: '@deepseek-ai/dsh-fs-policy'
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
- id: spill-local
name: '@deepseek-ai/dsh-spill-local'
config:
root: !!js process.env.DSH_SNAPSHOT_SPILL_ROOT ?? './.spill'
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: !!js process.env.DSH_SNAPSHOT && 800 || 50000
+1
View File
@@ -53,6 +53,7 @@ const SCENARIOS: Scenario[] = [
// Its prompt and tool-schema sidecars pin the composed header.
{ name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true },
{ name: 'tool-call-turn', hasModelTurn: true, recorded: true },
{ name: 'bash-spill', hasModelTurn: true, recorded: false, headerClass: 'fs', configPath: FS_CONFIG },
{ name: 'fs-terminal-card', hasModelTurn: true, recorded: true },
{ name: 'todo-plan', hasModelTurn: true, recorded: true },
{ name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' },
@@ -27,7 +27,7 @@ The available tools:
```ts
declare const tools: {
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
bash(args: {
/** The bash command to execute. */
command: string;
@@ -2,7 +2,7 @@
"initial": [
{
"name": "bash",
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Use the bash tool to print a large deterministic output, then reply DONE." }
]
}
@@ -0,0 +1,23 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"}
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the bash tool to print a large deterministic output, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_spill","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}}
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}}}
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}
{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-e194e47db58a/69c4a2d26b7e-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}}
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}}
{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":21,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_spill","title":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","kind":"execute","status":"in_progress","rawInput":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","content":[{"type":"content","content":{"type":"text","text":"Print large deterministic output"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: {{spillLocator:bash.txt}}. Use read with offset/limit, or grep this path to search within it.)\n```"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
@@ -27,7 +27,7 @@ The available tools:
```ts
declare const tools: {
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
bash(args: {
/** The bash command to execute. */
command: string;
@@ -2,7 +2,7 @@
"initial": [
{
"name": "bash",
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
@@ -27,7 +27,7 @@ The available tools:
```ts
declare const tools: {
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
bash(args: {
/** The bash command to execute. */
command: string;
@@ -33,7 +33,7 @@ The available tools:
```ts
declare const tools: {
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
bash(args: {
/** The bash command to execute. */
command: string;
@@ -131,8 +131,8 @@
{"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"}
{"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}
{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"b76bc320-c954-4f8a-b7d6-30821793dae8","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"b76bc320-c954-4f8a-b7d6-30821793dae8","outcome":"allowed-once"}}
{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"f2837399-691e-4913-abf4-9cd40aa31ac2","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"f2837399-691e-4913-abf4-9cd40aa31ac2","outcome":"allowed-once"}}
{"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"}
{"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}}
@@ -155,8 +155,8 @@
{"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"}
{"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}
{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"66efb593-279a-472b-b647-c50d34045bc0","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"66efb593-279a-472b-b647-c50d34045bc0","outcome":"rejected"}}
{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"e5a72cf8-322c-4ec1-a082-55903876dc53","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"e5a72cf8-322c-4ec1-a082-55903876dc53","outcome":"rejected"}}
{"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"}
{"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}}
@@ -55,8 +55,8 @@
{"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}}
{"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}}
{"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}}
{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"af257151-c371-4be2-a9ab-fbf4b6d18eb1","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}}
{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"af257151-c371-4be2-a9ab-fbf4b6d18eb1","outcome":"rejected"}}
{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"6f7aaf62-f3cf-435f-8338-e8de72dbfbbf","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}}
{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"6f7aaf62-f3cf-435f-8338-e8de72dbfbbf","outcome":"rejected"}}
{"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"}
{"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}}
@@ -2,7 +2,7 @@
"initial": [
{
"name": "bash",
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
@@ -2,7 +2,7 @@
"initial": [
{
"name": "bash",
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
@@ -2,7 +2,7 @@
"initial": [
{
"name": "bash",
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
@@ -2,7 +2,7 @@
"initial": [
{
"name": "bash",
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
@@ -2,7 +2,7 @@
"initial": [
{
"name": "bash",
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
+12
View File
@@ -47,6 +47,14 @@ flowchart LR
cfg --> plugin_coding_fs_policy
plugin_coding_tool_fs["tool-fs<br/>@deepseek-ai/dsh-tool-fs"]
cfg --> plugin_coding_tool_fs
plugin_coding_tool_fs_search["tool-fs-search<br/>@deepseek-ai/dsh-tool-fs-search"]
cfg --> plugin_coding_tool_fs_search
plugin_coding_timeout_policy["timeout-policy<br/>@deepseek-ai/dsh-timeout-policy"]
cfg --> plugin_coding_timeout_policy
plugin_coding_spill_local["spill-local<br/>@deepseek-ai/dsh-spill-local"]
cfg --> plugin_coding_spill_local
plugin_coding_spill_policy["spill-policy<br/>@deepseek-ai/dsh-spill-policy"]
cfg --> plugin_coding_spill_policy
```
| Plugin id | Package / module |
@@ -67,6 +75,10 @@ flowchart LR
| `fs-local` | `@deepseek-ai/dsh-fs-local` |
| `fs-policy` | `@deepseek-ai/dsh-fs-policy` |
| `tool-fs` | `@deepseek-ai/dsh-tool-fs` |
| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` |
| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` |
| `spill-local` | `@deepseek-ai/dsh-spill-local` |
| `spill-policy` | `@deepseek-ai/dsh-spill-policy` |
Source config: [`examples/coding-agent/cordis.yml`](cordis.yml).
+26
View File
@@ -114,3 +114,29 @@
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
# Bash-backed discovery tools (glob/grep): fixed ripgrep commands through the
# local bash executor above — not ctx.fs. Capped results save the complete
# formatted list through the spill backend below (ctx.spillStore, optional).
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
# The tool-call timeout enforcer: arms each declared ToolDefinition.timeoutMs
# (the search tools above declare 30s) as a deadline on exec.signal. Without
# it a declared budget is advisory and only the bash executor's own timeout
# backstop applies.
- id: timeout-policy
name: '@deepseek-ai/dsh-timeout-policy'
# Tool-output spill stack: a local backend that saves oversized tool text under
# a private session-scoped dir, and the tools/post-execute policy that replaces
# an over-budget plain-text result with a preview + the spill locator/retrieval
# hint. A leaf pair after the app (needs ctx.tools). The policy is a no-op until
# a tool returns more than maxInlineBytes of plain text.
- id: spill-local
name: '@deepseek-ai/dsh-spill-local'
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: 50000
+4 -10
View File
@@ -1,11 +1,7 @@
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
@@ -50,11 +46,9 @@ export interface CodingHarnessOptions {
export async function codingHarness(workdir: string, options: CodingHarnessOptions = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: options.persona ?? '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await mountAgentLoopTestDependencies(ctx, {
systemPrompt: { persona: options.persona ?? '' },
})
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
+4 -10
View File
@@ -1,10 +1,6 @@
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
@@ -23,11 +19,9 @@ const PERSONA = 'You are cordis-agent, a self-referential harness demo. '
export async function cordisHarness(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: PERSONA })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await mountAgentLoopTestDependencies(ctx, {
systemPrompt: { persona: PERSONA },
})
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
await ctx.plugin(ToolCordis)
+15
View File
@@ -35,11 +35,21 @@
"project": ["src/**/*.ts"],
"ignoreDependencies": ["cordis"]
},
"packages/util/home": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
"ignoreDependencies": ["cordis"]
},
"packages/util/timeout": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
"ignoreDependencies": ["cordis"]
},
"packages/util/retention": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
"ignoreDependencies": ["cordis"]
},
"packages/support/acp-snapshot": {
"entry": ["tests/**/*.spec.ts", "tests/fixtures/fake-acp-agent.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
@@ -132,6 +142,11 @@
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
},
"packages/fs/tool-fs-search": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
"ignoreBinaries": ["rg"]
},
"packages/mcp/mcp-client": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
+7 -6
View File
@@ -13,7 +13,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface |
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, the model-facing file tools, and the bash-backed discovery tools | Product — stable surface |
| [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface |
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface |
@@ -21,24 +21,25 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, the worker-thread engine, and the model-facing `workflow` tool | Product — stable surface |
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
| [`spill/`](spill/README.md) | Spill capability family: the storage seam, a local impl, and the tool-result spill policy | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval: logical corpus, bounded reads, lineage, and event relationships | Product — stable surface |
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/ACP/JSON-RPC bins) the leaves load | Support — example infra |
| [`support/`](support/README.md) | Support infrastructure (invariants, replay, Loader smokes, subagent mock) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, path helpers) | Support — small, stable, harness-dep-free |
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes, subagent mock) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
Groups distinguish product API from support infrastructure. New packages join an existing group; a new group updates its README and this table.
## Dependencies
The inter-package dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
The dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-spine-demo`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
+2 -2
View File
@@ -23,8 +23,8 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi;
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
- **Model-friendly env + credential scrub**`process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
- **Model-friendly env + credential scrub**`process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Background processes**`start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
## Model Experience
+15 -4
View File
@@ -92,15 +92,22 @@ export class LocalBashExecutor extends BashExecutor {
this.config.maxTimeoutMs,
'bash-local: request.timeoutMs',
)
const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes
assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)
return {
command: request.command,
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
timeoutMs,
stdoutMaxBytes,
...request.signal ? { signal: request.signal } : {},
// Explicit environment values are merged after credential scrubbing in run.ts.
// Carry stdin/ordinary env/trusted dshEnv through verbatim — optional,
// no config default. run.ts owns the scrub and merge order.
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
// Local execution carries this override for sandboxing subclasses.
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
// Carry a sandbox-mode override through verbatim: this executor never
// confines, so the field is inert here (the seam contract) — a
// sandboxing subclass overrides resolve() to stamp its default instead.
sandboxMode: request.sandboxMode,
}
}
@@ -111,11 +118,13 @@ export class LocalBashExecutor extends BashExecutor {
const outcome = await runBash({
command: spec.command,
cwd: spec.workdir,
maxOutputBytes: this.config.maxOutputBytes,
stdoutMaxBytes: spec.stdoutMaxBytes,
stderrMaxBytes: this.config.maxOutputBytes,
graceMs: this.config.graceMs,
signal: d.signal,
stdin: spec.stdin,
env: spec.env,
dshEnv: spec.dshEnv,
}, this.internals).done
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
@@ -128,11 +137,13 @@ export class LocalBashExecutor extends BashExecutor {
const running = runBash({
command: spec.command,
cwd: spec.workdir,
maxOutputBytes: this.config.maxOutputBytes,
stdoutMaxBytes: this.config.maxOutputBytes,
stderrMaxBytes: this.config.maxOutputBytes,
graceMs: this.config.graceMs,
signal: spec.signal,
stdin: spec.stdin,
env: spec.env,
dshEnv: spec.dshEnv,
}, this.internals)
let stdoutOffset = 0
+35 -17
View File
@@ -11,7 +11,8 @@ import { randomBytes } from 'node:crypto'
import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { CollectedOutput } from '@deepseek-ai/dsh-bash'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash'
/**
* Model-friendly environment overrides: disable colors, pagers, and
@@ -34,26 +35,43 @@ export const ENV_OVERRIDES = {
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/**
* Build a child environment by scrubbing credential-shaped ambient variables,
* applying model-friendly overrides, then merging trusted caller entries last.
*
* @param extra - caller-supplied entries merged last; an explicit entry wins even against the scrub and the overrides.
* Build a child environment from scrubbed ambient values, terminal overrides,
* ordinary caller entries, and a managed `DSH_*` snapshot. Ambient managed
* names are removed; ordinary and managed entries reject the other channel's
* namespace before `dshEnv` merges last.
* @param extra - caller entries; `DSH_*` names are rejected.
* @param dshEnv - managed entries; non-`DSH_*` names are rejected.
* @returns the environment to hand to `spawn` for the child process.
*/
export function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
export function childEnv(
extra?: Readonly<Record<string, string>>,
dshEnv?: DshEnvironment,
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
for (const [key, value] of Object.entries(process.env)) {
if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
if (!SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value
}
return { ...env, ...ENV_OVERRIDES, ...extra }
for (const key of Object.keys(extra ?? {})) {
if (key.startsWith(DSH_ENV_PREFIX)) {
throw new Error(`ordinary bash env cannot set reserved variable "${key}"; use dshEnv`)
}
}
for (const key of Object.keys(dshEnv ?? {})) {
if (!key.startsWith(DSH_ENV_PREFIX)) {
throw new Error(`managed bash env cannot set ordinary variable "${key}"; use env`)
}
}
return { ...env, ...ENV_OVERRIDES, ...extra, ...dshEnv }
}
/** What to run and under which limits (resolved — no defaults in here). */
export interface SpawnSpec {
command: string
cwd: string
/** Per-stream in-memory cap; overflow spills to disk (tail kept in memory). */
maxOutputBytes: number
/** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */
stdoutMaxBytes: number
/** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */
stderrMaxBytes: number
/** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
graceMs: number
/**
@@ -71,12 +89,12 @@ export interface SpawnSpec {
*/
stdin?: string | undefined
/**
* Extra environment entries, merged onto the scrubbed env AFTER the
* credential scrub and the model-friendly overrides (so an explicit entry
* wins). Set by in-process plugins; the model-facing tool does not forward
* model input here.
* Ordinary environment entries merged after the credential scrub and
* terminal overrides. `DSH_*` names are rejected and belong in `dshEnv`.
*/
env?: Record<string, string> | undefined
/** Harness-owned entries; non-`DSH_*` names are rejected before spawn. */
dshEnv?: DshEnvironment | undefined
}
/**
@@ -278,13 +296,13 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
}
// Keep absent stdin as /dev/null; literal tuples preserve non-null output types.
const env = childEnv(spec.env)
const env = childEnv(spec.env, spec.dshEnv)
const child: ChildProcessByStdio<Writable | null, Readable, Readable> = spec.stdin !== undefined
? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
: spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
const stdout = new OutputCollector(spec.maxOutputBytes, 'stdout', spillDir)
const stderr = new OutputCollector(spec.maxOutputBytes, 'stderr', spillDir)
const stdout = new OutputCollector(spec.stdoutMaxBytes, 'stdout', spillDir)
const stderr = new OutputCollector(spec.stderrMaxBytes, 'stderr', spillDir)
child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) })
child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })
@@ -71,6 +71,23 @@ describe('LocalBashExecutor.run', () => {
const { bash } = await setup()
expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
expect(() => bash.resolve({ command: 'true', timeoutMs: -1 })).toThrow(/request\.timeoutMs/)
expect(() => bash.resolve({ command: 'true', stdoutMaxBytes: Number.NaN })).toThrow(/request\.stdoutMaxBytes/)
expect(() => bash.resolve({ command: 'true', stdoutMaxBytes: -1 })).toThrow(/request\.stdoutMaxBytes/)
})
it('defaults stdoutMaxBytes to maxOutputBytes and lets foreground callers raise stdout only', async () => {
const { bash } = await setup({ maxOutputBytes: 100 })
expect(bash.resolve({ command: 'true' }).stdoutMaxBytes).toBe(100)
const result = await bash.run(bash.resolve({
command: 'printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2',
stdoutMaxBytes: 500,
}))
expect(result.stdout.truncated).toBe(false)
expect(result.stdout.text).toBe('x'.repeat(500))
expect(result.stderr.truncated).toBe(true)
expect(result.stderr.text.length).toBeLessThanOrEqual(100)
})
it('per-call timeout takes precedence under the cap and kills on expiry', async () => {
@@ -110,21 +127,28 @@ describe('LocalBashExecutor.run', () => {
await expect(bash.run(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/)
})
it('resolve() carries stdin/env onto the spec, and run() threads them to the command', async () => {
it('resolve() carries stdin/env/dshEnv onto the spec, and run() threads them to the command', async () => {
const { bash } = await setup()
const spec = bash.resolve({ command: 'cat; echo "[$DSH_SEAM_VAR]"', stdin: 'piped\n', env: { DSH_SEAM_VAR: 'env-ok' } })
// resolve() keeps the stdin/env fields verbatim (optional, no default).
const spec = bash.resolve({
command: 'cat; echo "[$SEAM_VAR][$DSH_SEAM_VAR]"',
stdin: 'piped\n',
env: { SEAM_VAR: 'env-ok' },
dshEnv: { DSH_SEAM_VAR: 'dsh-ok' },
})
// resolve() keeps the optional input/environment fields verbatim.
expect(spec.stdin).toBe('piped\n')
expect(spec.env).toEqual({ DSH_SEAM_VAR: 'env-ok' })
expect(spec.env).toEqual({ SEAM_VAR: 'env-ok' })
expect(spec.dshEnv).toEqual({ DSH_SEAM_VAR: 'dsh-ok' })
const result = await bash.run(spec)
expect(result.stdout.text).toBe('piped\n[env-ok]\n')
expect(result.stdout.text).toBe('piped\n[env-ok][dsh-ok]\n')
})
it('resolve() omits stdin/env when the request supplies neither', async () => {
it('resolve() omits stdin/env/dshEnv when the request supplies none', async () => {
const { bash } = await setup()
const spec = bash.resolve({ command: 'true' })
expect('stdin' in spec).toBe(false)
expect('env' in spec).toBe(false)
expect('dshEnv' in spec).toBe(false)
})
})
@@ -143,11 +167,12 @@ describe('LocalBashExecutor.start (background process handles)', () => {
it('threads stdin and extra env into a background process', async () => {
const { bash } = await setup()
const proc = bash.start(bash.resolve({
command: 'cat; echo "[$DSH_BG_VAR]"',
command: 'cat; echo "[$BG_VAR][$DSH_BG_VAR]"',
stdin: 'bg-stdin\n',
env: { DSH_BG_VAR: 'bg-env' },
env: { BG_VAR: 'bg-env' },
dshEnv: { DSH_BG_VAR: 'bg-dsh-env' },
}))
const output = await readUntil(proc, '[bg-env]')
const output = await readUntil(proc, '[bg-env][bg-dsh-env]')
expect(output).toContain('bg-stdin')
await proc.done
expect(proc.exitCode).toBe(0)
+53 -14
View File
@@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { DshEnvironment } from '@deepseek-ai/dsh-bash'
import { killGroup, OutputCollector, runBash } from '../src/run.ts'
import type { RunningBash } from '../src/run.ts'
@@ -26,7 +27,8 @@ function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]>
return {
command,
cwd: process.cwd(),
maxOutputBytes: 64_000,
stdoutMaxBytes: 64_000,
stderrMaxBytes: 64_000,
graceMs: 3_000,
...overrides,
}
@@ -197,19 +199,19 @@ describe('stdin and extra env (set by in-process plugins)', () => {
expect(piped.stdout.text).toBe('socket\n')
})
it('merges extra env entries onto the scrubbed environment', async () => {
const result = await runBash(spec('echo "$DSH_EXTRA_ONE/$DSH_EXTRA_TWO"', {
env: { DSH_EXTRA_ONE: 'alpha', DSH_EXTRA_TWO: 'beta' },
it('merges ordinary extra env entries onto the scrubbed environment', async () => {
const result = await runBash(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', {
env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' },
})).done
expect(result.stdout.text).toBe('alpha/beta\n')
})
it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => {
// TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins.
// DSH_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
// EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
// entry is still honored — the scrub only drops AMBIENT process.env creds.
const result = await runBash(spec('echo "$TERM/$DSH_OVERRIDE_KEY"', {
env: { TERM: 'xterm-256color', DSH_OVERRIDE_KEY: 'explicit-wins' },
const result = await runBash(spec('echo "$TERM/$EXPLICIT_OVERRIDE_KEY"', {
env: { TERM: 'xterm-256color', EXPLICIT_OVERRIDE_KEY: 'explicit-wins' },
})).done
expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n')
})
@@ -224,10 +226,24 @@ describe('stdin and extra env (set by in-process plugins)', () => {
})
describe('output truncation and spill', () => {
it('applies stdout and stderr caps independently', async () => {
const result = await runBash(
spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', {
stdoutMaxBytes: 500,
stderrMaxBytes: 100,
}),
{ spillDir },
).done
expect(result.stdout.truncated).toBe(false)
expect(result.stdout.text).toBe('x'.repeat(500))
expect(result.stderr.truncated).toBe(true)
expect(result.stderr.text.length).toBeLessThanOrEqual(100)
})
it('keeps the tail and spills the full stream to disk', async () => {
// 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.
const result = await runBash(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
expect(result.stdout.truncated).toBe(true)
@@ -242,7 +258,7 @@ describe('output truncation and spill', () => {
it('does not truncate output exactly at the cap', async () => {
const result = await runBash(
spec('printf "%.0sx" $(seq 1 500)', { maxOutputBytes: 500 }),
spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
expect(result.stdout.truncated).toBe(false)
@@ -253,7 +269,7 @@ describe('output truncation and spill', () => {
it('settles with the tail and no spill path when final spill close fails', async () => {
failNextClose.value = true
const result = await runBash(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
expect(failNextClose.value).toBe(false)
@@ -348,13 +364,13 @@ describe('abort edge cases', () => {
})
describe('environment and spill-file hardening', () => {
it('scrubs credential-shaped env vars from child processes', async () => {
it('scrubs credential-shaped and ambient DSH env vars from child processes', async () => {
process.env.DSH_TEST_API_KEY = 'super-secret'
process.env.DSH_TEST_TOKEN = 'also-secret'
process.env.DSH_TEST_PLAIN = 'visible'
try {
const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done
expect(result.stdout.text.trim()).toBe('[absent|absent|visible]')
expect(result.stdout.text.trim()).toBe('[absent|absent|absent]')
} finally {
delete process.env.DSH_TEST_API_KEY
delete process.env.DSH_TEST_TOKEN
@@ -362,9 +378,32 @@ describe('environment and spill-file hardening', () => {
}
})
it('injects only the current trusted DSH environment after scrubbing ambient values', async () => {
process.env.DSH_STALE = 'old-value'
try {
const result = await runBash(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', {
dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' },
})).done
expect(result.stdout.text.trim()).toBe('[absent|1|current-session]')
} finally {
delete process.env.DSH_STALE
}
})
it('rejects DSH variables on the ordinary env channel', () => {
expect(() => runBash(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } })))
.toThrow(/DSH_WRONG_CHANNEL.*dshEnv/)
})
it('rejects ordinary variables on the managed env channel', () => {
const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment
expect(() => runBash(spec('true', { dshEnv: invalid })))
.toThrow(/managed bash env.*PATH.*use env/)
})
it('creates spill files with owner-only permissions and random names', async () => {
const result = await runBash(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
{ spillDir },
).done
const path = result.stdout.spillPath!
@@ -375,7 +414,7 @@ describe('environment and spill-file hardening', () => {
it('defaults spills into a private per-process directory', async () => {
const result = await runBash(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
).done
const dir = dirname(result.stdout.spillPath!)
expect(dir).toMatch(/dsh-bash-/)
+2 -2
View File
@@ -27,11 +27,11 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
## Vocabulary
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, sandboxMode) before execution. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing.
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxMode) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing.
The seam also owns the per-session mode override vocabulary: the log-only `'bash/sandbox-mode'` session event, the pure `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path. `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec; a missing value means "none". See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
## Model Experience
+3
View File
@@ -9,6 +9,7 @@ import { Context, Service } from 'cordis'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts'
export { DSH_ENV_PREFIX } from './types.ts'
export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
export type {
BashExecRequest,
@@ -19,6 +20,8 @@ export type {
BashRunResult,
BashSandboxInfo,
CollectedOutput,
DshEnvironment,
DshEnvironmentKey,
} from './types.ts'
declare module 'cordis' {
+51 -11
View File
@@ -6,6 +6,15 @@
import type { SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */
export const DSH_ENV_PREFIX = 'DSH_' as const
/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
/** Trusted DeepSeek Harness variables for one bash execution. */
export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>
/**
* Sandbox facts for one run, present iff a sandboxing executor handled it.
* Facts are reported independently of process exit status so callers can
@@ -34,6 +43,13 @@ export interface BashExecRequest {
workdir?: string | undefined
/** Timeout override in milliseconds (implementations cap it). */
timeoutMs?: number | undefined
/**
* Foreground stdout capture budget in bytes. Absent uses the executor's
* default output cap. Trusted in-process consumers use this when they must
* parse complete stdout up to their own bounded limit; the model-facing bash
* tool does not expose it as a parameter.
*/
stdoutMaxBytes?: number | undefined
/** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | undefined
/**
@@ -45,15 +61,20 @@ export interface BashExecRequest {
*/
stdin?: string | undefined
/**
* Extra environment entries for the command, merged AFTER the
* implementation's credential scrub (so an explicit entry here is honored even
* when its name matches the scrub pattern the caller named a value it holds,
* not the harness's ambient secret). Set by in-process plugins (the hooks
* bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, ); the model-facing
* bash tool does not expose it as a parameter (a model that needs an env var
* uses shell syntax like `FOO=bar cmd`).
* Ordinary environment entries for the command, merged after the credential
* scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it
* here. Set by in-process plugins (the hooks bridges set
* `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, ); the model-facing bash tool
* does not expose it as a parameter.
*/
env?: Record<string, string> | undefined
/**
* Harness-owned `DSH_*` variables for this execution. Executors discard
* ambient `DSH_*` entries before merging this snapshot, so an unavailable
* current fact cannot inherit a stale value from the harness process, and
* reject non-`DSH_*` names supplied through this managed channel.
*/
dshEnv?: DshEnvironment | undefined
/** Explicit per-call sandbox mode override. */
sandboxMode?: SandboxMode | undefined
}
@@ -67,15 +88,24 @@ export interface BashExecSpec {
command: string
workdir: string
timeoutMs: number
/**
* Resolved foreground stdout capture budget in bytes. `run()` uses it for
* stdout; background tasks and stderr keep the executor's own output cap.
*/
stdoutMaxBytes: number
/** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | undefined
/** Bytes to write to stdin before closing it; absent means no stdin. */
stdin?: string | undefined
/**
* Extra environment entries, merged after credential scrubbing so explicit
* values win; absent means no extra entries.
* Ordinary environment entries carried through from
* {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}.
* OPTIONAL on the spec for the same reason as `stdin`: absent means no
* ordinary extra environment.
*/
env?: Record<string, string> | undefined
/** Managed `DSH_*` snapshot; implementations reject ordinary names. */
dshEnv?: DshEnvironment | undefined
/** Resolved sandbox mode; ignored by executors that do not confine. */
sandboxMode: SandboxMode | undefined
}
@@ -96,9 +126,19 @@ export interface BashRunResult {
exitCode: number | null
/** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */
signal: NodeJS.Signals | null
/** True when the executor's own timeout killed the command. */
/**
* True when the executor's own timeout was the FIRST cause to cut the command
* short. Mutually exclusive with {@link aborted}: one fused deadline drives
* both the timeout and the caller's cancellation, so a timeout and an abort
* racing before process close report the single first-abort cause, not both
* (see the [timeout-library RFC](../../../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
*/
timedOut: boolean
/** True when the caller's AbortSignal killed the command. */
/**
* True when the caller's `AbortSignal` was the FIRST cause to kill the command
* (and it was not the executor's own timeout). Mutually exclusive with
* {@link timedOut} see there for the first-cause classification.
*/
aborted: boolean
/** The effective timeout applied to this run (after defaulting/capping). */
timeoutMs: number
+2 -1
View File
@@ -15,6 +15,7 @@ class StubExecutor extends BashExecutor {
command: request.command,
workdir: request.workdir ?? '/stub',
timeoutMs: request.timeoutMs ?? 1000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
...request.signal ? { signal: request.signal } : {},
sandboxMode: request.sandboxMode,
}
@@ -54,7 +55,7 @@ describe('BashExecutor service seam', () => {
const ctx = new Context()
await ctx.plugin(StubExecutor)
const spec = ctx.bash.resolve({ command: 'echo hi' })
expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000, sandboxMode: undefined })
expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000, stdoutMaxBytes: 64_000, sandboxMode: undefined })
const result = await ctx.bash.run(spec)
expect(result.exitCode).toBe(0)
+24 -1
View File
@@ -24,6 +24,29 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer (from the calling agent's `session.header.cwd`) BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
### Managed shell environment
Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-home`](../../util/home/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=<absolute target path>`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential.
`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; tool-bash's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam.
```ts
import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-tool-bash'
export const inject = ['bashEnv']
export function apply(ctx: Context): void {
ctx.bashEnv.register({
name: 'deployment-region',
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
})
}
```
The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executor removes all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section.
Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`.
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
@@ -34,7 +57,7 @@ The tool owns its `presentCall`/`presentResult` render intent. A foreground call
## The tool builds its request from named args only
The `BashExecRequest` seam carries optional `stdin` and `env`, used by trusted in-process plugins. This tool does **not** expose or forward them: it builds requests from named command/workdir/timeout/signal/sandbox fields only. This is not a trust boundary; the local executor's ambient credential scrub is the security control.
The `BashExecRequest` seam carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
## Permissions and escalation
+6
View File
@@ -25,7 +25,9 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-home": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
@@ -38,12 +40,16 @@
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-home": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
+191 -4
View File
@@ -8,34 +8,203 @@
* @module @deepseek-ai/dsh-tool-bash
*/
import type { Context } from 'cordis'
import { Service, type Context } from 'cordis'
import z from 'schemastery'
import { isAbsolute, resolve as resolvePath } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-session-persistence'
import { assertNever } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tasks'
import type {} from '@deepseek-ai/dsh-user-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
import { DSH_ENV_PREFIX, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home'
import { processOutcome } from './background.ts'
import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'
declare module 'cordis' {
interface Context {
bashEnv: BashEnvRegistry
}
}
export const name = 'tool-bash'
export const inject = ['tools', 'bash', 'systemPrompt']
/** Configures whether the model may background commands. */
/** Configuration for the bash tool and its managed child environment. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
/** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
}
/** Runtime configuration schema for the bash tool plugin. */
export const Config: z<Config> = z.object({
enableRunInBackground: z.boolean().default(true),
dshHome: z.string(),
})
/** Model-visible metadata for one managed `DSH_*` environment variable. */
export interface BashEnvVariable {
/** Concise description of the environment fact represented by the variable. */
description: string
}
/**
* A plugin contribution to the managed environment of each model bash call.
* Declared keys make ownership conflicts detectable before the first command;
* `resolve` computes only the values available for the current execution.
*/
export interface BashEnvContributor {
/** Stable contributor name used in diagnostics and duplicate detection. */
name: string
/** Complete set of `DSH_*` keys this contributor may return. */
variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>>
/**
* Resolve this contributor's available values for one tool execution.
* @param execution - the bash tool execution and its optional calling agent.
* @returns a partial map containing only keys declared in {@link variables}.
*/
resolve(execution: ToolExecution): Readonly<Partial<Record<DshEnvironmentKey, string>>>
}
/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */
export interface BashEnvVariableInfo extends BashEnvVariable {
/** Contributor that owns the variable. */
contributor: string
/** Declared `DSH_*` environment variable name. */
key: DshEnvironmentKey
}
const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const
const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const
const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const
const RESERVED_BASH_ENV_KEYS = new Set<DshEnvironmentKey>([
DSH_HOME_ENV,
DSH_SHELL_KEY,
DSH_SESSION_ID_KEY,
])
const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/
/**
* Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables.
* The namespace is rebuilt for every model bash call: ambient `DSH_*` values
* are discarded by the executor, then the registry's current snapshot is
* injected. Built-in shell facts remain owned by the registry itself while
* plugins can register additional, enumerable facts with effect-scoped
* disposal.
*/
export class BashEnvRegistry extends Service {
private readonly contributors = new Map<string, BashEnvContributor>()
private readonly keyOwners = new Map<DshEnvironmentKey, string>()
private readonly dshHome: string
/**
* Create and install the `ctx.bashEnv` service.
* @param ctx - Cordis context that owns the service and registrations.
* @param config - home-directory configuration for the built-in variables.
*/
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'bashEnv')
this.dshHome = resolveDshHome(config.dshHome)
}
/**
* Register one environment contributor. Names and keys are unique; built-in
* keys are reserved. Registration is disposed with the calling plugin fiber.
* @param contributor - declared key ownership and per-execution resolver.
* @returns the disposer that unregisters the contribution.
*/
register(contributor: BashEnvContributor): () => void {
const dispose = this.ctx.effect(function* (this: BashEnvRegistry) {
if (contributor.name.trim().length === 0) {
throw new Error('bash env contributor name must be non-empty')
}
if (this.contributors.has(contributor.name)) {
throw new Error(`bash env contributor "${contributor.name}" is already registered`)
}
const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][]
for (const [key, variable] of variables) {
if (!key.startsWith(DSH_ENV_PREFIX)
|| !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) {
throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`)
}
if (RESERVED_BASH_ENV_KEYS.has(key)) {
throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`)
}
if (variable.description.trim().length === 0) {
throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`)
}
const owner = this.keyOwners.get(key)
if (owner !== undefined) {
throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`)
}
}
this.contributors.set(contributor.name, contributor)
for (const [key] of variables) this.keyOwners.set(key, contributor.name)
yield () => {
this.contributors.delete(contributor.name)
for (const [key] of variables) this.keyOwners.delete(key)
}
}.bind(this), 'bashEnv.register()')
return () => void dispose()
}
/**
* Build the trusted `DSH_*` snapshot for one bash tool execution.
* @param execution - the current tool execution.
* @returns an immutable environment overlay containing built-ins and current contributions.
*/
collect(execution: ToolExecution): DshEnvironment {
const values: Record<DshEnvironmentKey, string> = {
[DSH_HOME_ENV]: this.dshHome,
[DSH_SHELL_KEY]: '1',
}
if (execution.agent !== undefined) {
values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id
}
for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) {
const resolved = contributor.resolve(execution)
for (const [rawKey, value] of Object.entries(resolved)) {
const key = rawKey as DshEnvironmentKey
if (!Object.hasOwn(contributor.variables, key)) {
throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`)
}
if (typeof value !== 'string') {
throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`)
}
values[key] = value
}
}
return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right))))
}
// TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics,
// prompt, or UI code treats list() as an exhaustive environment catalog.
/**
* Enumerate plugin-contributed variables without executing their resolvers.
* @returns declarations sorted by environment variable name.
*/
list(): BashEnvVariableInfo[] {
return [...this.contributors.values()]
.flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({
contributor: contributor.name,
description: variable.description,
key: key as DshEnvironmentKey,
})))
.sort((left, right) => left.key.localeCompare(right.key))
}
}
/** Parsed tool args; execute validates value constraints absent from SchemaSpec. */
interface BashToolArgs {
command: string
@@ -82,6 +251,7 @@ function bashDescription(backgroundEnabled: boolean, escalationModes: readonly S
const base = 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
+ 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — '
+ 'pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. '
+ `Current harness environment facts are exposed through managed \`$${DSH_ENV_PREFIX}*\` variables; inspect them when needed. `
+ 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. '
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
+ background
@@ -153,7 +323,22 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent
return modelWorkdir
}
export function apply(ctx: Context, config: Config): void {
export function apply(ctx: Context, config: Config = {}): void {
const bashEnv = new BashEnvRegistry(ctx, config)
bashEnv.register({
name: 'session-persistence',
variables: {
[DSH_SESSION_JSONL_KEY]: {
description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.',
},
},
resolve(execution) {
const agent = execution.agent
if (agent === undefined) return {}
const location = ctx.get('sessionPersistence')?.locate(agent.session.header)
return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {}
},
})
const backgroundEnabled = config.enableRunInBackground ?? true
const defaultMode = ctx.bash.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
@@ -235,10 +420,12 @@ export function apply(ctx: Context, config: Config): void {
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
: sessionOverride(exec)
const workdir = resolveWorkdir(args.workdir, exec)
const dshEnv = bashEnv.collect(exec)
const request = {
command: args.command,
...workdir !== undefined ? { workdir } : {},
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
dshEnv,
...sandboxMode !== undefined ? { sandboxMode } : {},
}
if (args.run_in_background === true) {
@@ -0,0 +1,190 @@
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash'
afterEach(() => vi.unstubAllEnvs())
function execution(sessionId?: string): ToolExecution {
return {
token: Symbol('bash-env-test') as ToolExecution['token'],
callId: CallId('bash-env-call'),
name: 'bash',
arguments: { command: 'true' },
...(sessionId === undefined
? {}
: { agent: { session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as Agent }),
}
}
describe('BashEnvRegistry', () => {
it('collects unconditional shell facts and the current agent session id', () => {
const ctx = new Context()
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
expect(registry.collect(execution())).toEqual({
DSH_HOME: resolve('./test-dsh-home'),
DSH_SHELL: '1',
})
expect(registry.collect(execution('session-a'))).toEqual({
DSH_HOME: resolve('./test-dsh-home'),
DSH_SESSION_ID: 'session-a',
DSH_SHELL: '1',
})
})
it('resolves DSH_HOME from the ambient override or the user-home default', () => {
vi.stubEnv('DSH_HOME', './ambient-dsh-home')
const fromEnvironment = new BashEnvRegistry(new Context())
expect(fromEnvironment.collect(execution()).DSH_HOME).toBe(resolve('./ambient-dsh-home'))
vi.stubEnv('DSH_HOME', undefined)
const fromDefault = new BashEnvRegistry(new Context())
expect(fromDefault.collect(execution()).DSH_HOME).toBe(join(homedir(), '.dsh'))
})
it('collects declared contributor variables and omits unavailable values', () => {
const ctx = new Context()
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
registry.register({
name: 'optional-session-fact',
variables: {
DSH_SESSION_OPTIONAL: { description: 'Optional session-scoped test fact.' },
},
resolve: exec => exec.agent === undefined ? {} : { DSH_SESSION_OPTIONAL: exec.agent.session.header.id },
})
registry.register({
name: 'always-available-fact',
variables: {
DSH_ALWAYS_AVAILABLE: { description: 'Always-available test fact.' },
},
resolve: () => ({ DSH_ALWAYS_AVAILABLE: 'yes' }),
})
expect(registry.collect(execution())).not.toHaveProperty('DSH_SESSION_OPTIONAL')
expect(registry.collect(execution()).DSH_ALWAYS_AVAILABLE).toBe('yes')
expect(registry.collect(execution('session-b')).DSH_SESSION_OPTIONAL).toBe('session-b')
expect(registry.list()).toEqual([
{
contributor: 'always-available-fact',
description: 'Always-available test fact.',
key: 'DSH_ALWAYS_AVAILABLE',
},
{
contributor: 'optional-session-fact',
description: 'Optional session-scoped test fact.',
key: 'DSH_SESSION_OPTIONAL',
},
])
})
it('rejects duplicate variable ownership at registration time', () => {
const ctx = new Context()
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
registry.register({
name: 'first',
variables: { DSH_SHARED: { description: 'First owner.' } },
resolve: () => ({ DSH_SHARED: 'first' }),
})
expect(() => registry.register({
name: 'second',
variables: { DSH_SHARED: { description: 'Second owner.' } },
resolve: () => ({ DSH_SHARED: 'second' }),
})).toThrow(/DSH_SHARED.*first.*second|DSH_SHARED.*second.*first/)
})
it('rejects duplicate contributor names and malformed declarations', () => {
const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
registry.register({
name: 'declared',
variables: { DSH_DECLARED: { description: 'Declared fact.' } },
resolve: () => ({}),
})
expect(() => registry.register({
name: 'declared',
variables: { DSH_ANOTHER: { description: 'Another fact.' } },
resolve: () => ({}),
})).toThrow(/already registered/)
expect(() => registry.register({
name: ' ',
variables: { DSH_BLANK_NAME: { description: 'Blank owner.' } },
resolve: () => ({}),
})).toThrow(/name must be non-empty/)
expect(() => registry.register({
name: 'invalid-key',
variables: { dsh_invalid: { description: 'Invalid key.' } } as unknown as Record<'DSH_INVALID', { description: string }>,
resolve: () => ({}),
})).toThrow(/invalid key/)
expect(() => registry.register({
name: 'reserved-key',
variables: { DSH_HOME: { description: 'Reserved key.' } },
resolve: () => ({}),
})).toThrow(/reserved key/)
expect(() => registry.register({
name: 'blank-description',
variables: { DSH_BLANK_DESCRIPTION: { description: ' ' } },
resolve: () => ({}),
})).toThrow(/must describe/)
})
it('rejects undeclared variables returned by a contributor', () => {
const ctx = new Context()
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
registry.register({
name: 'drifted-provider',
variables: { DSH_DECLARED: { description: 'Declared fact.' } },
resolve: () => ({ DSH_UNDECLARED: 'bad' }),
})
expect(() => registry.collect(execution())).toThrow(/drifted-provider.*DSH_UNDECLARED/)
})
it('rejects non-string values returned by a contributor', () => {
const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
registry.register({
name: 'wrong-value-type',
variables: { DSH_STRING: { description: 'String fact.' } },
resolve: () => ({ DSH_STRING: 42 }) as unknown as Record<'DSH_STRING', string>,
})
expect(() => registry.collect(execution())).toThrow(/wrong-value-type.*non-string.*DSH_STRING/)
})
it('removes an effect-scoped contributor when its plugin is disposed', async () => {
const ctx = new Context()
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
const fiber = await ctx.plugin({
inject: ['bashEnv'],
apply(inner: Context) {
inner.bashEnv.register({
name: 'temporary',
variables: { DSH_TEMPORARY: { description: 'Temporary fact.' } },
resolve: () => ({ DSH_TEMPORARY: 'present' }),
})
},
})
expect(registry.collect(execution()).DSH_TEMPORARY).toBe('present')
await fiber.dispose()
expect(registry.collect(execution())).not.toHaveProperty('DSH_TEMPORARY')
})
it('returns an explicit contributor disposer', () => {
const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
const dispose = registry.register({
name: 'explicit-disposal',
variables: { DSH_EXPLICIT_DISPOSAL: { description: 'Explicitly disposed fact.' } },
resolve: () => ({ DSH_EXPLICIT_DISPOSAL: 'present' }),
})
expect(registry.collect(execution()).DSH_EXPLICIT_DISPOSAL).toBe('present')
dispose()
expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL')
})
})
@@ -1,12 +1,13 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
@@ -19,22 +20,25 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
* (tool/call + tool/result session events, the generic `ctx.tasks` runtime,
* agent.inject completion notices).
*/
async function harness(adapter: MockAdapter) {
async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: string) {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await mountAgentLoopTestDependencies(ctx)
if (sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: sessionRoot })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(ToolBash)
await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
const dirs: string[] = []
afterEach(() => {
vi.unstubAllEnvs()
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
@@ -82,6 +86,39 @@ async function pollUntil(predicate: () => boolean, timeoutMs = 5_000): Promise<v
}
describe('bash tool through the agent loop', () => {
it('first-turn bash receives session identity before the lazy JSONL file materializes', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-bash-session-env-'))
dirs.push(root)
const dshHome = join(root, 'dsh-home')
vi.stubEnv('DSH_STALE_PARENT', 'stale')
const adapter = new MockAdapter([
toolCallResponse('call-1', 'bash', {
command: 'printf \'%s\\n%s\\n%s\\n%s\\n%s\\n\' "$DSH_HOME" "$DSH_SHELL" "$DSH_SESSION_ID" "$DSH_SESSION_JSONL" "${DSH_STALE_PARENT-unset}"; if [ -e "$DSH_SESSION_JSONL" ]; then printf \'present\\n\'; else printf \'absent\\n\'; fi',
description: 'inspect session environment',
}),
textResponse('Session environment inspected.'),
])
const ctx = await harness(adapter, root, dshHome)
const handle = await ctx.agents.create({
agentId: AgentId('session-env'),
sessionId: SessionId('session-env-id'),
agentOptions: { model: 'mock' },
})
const agent = handle.agent as ReactLoopAgent
const location = ctx.sessionPersistence.locate(agent.session.header)
expect(location?.kind).toBe('jsonl')
agent.send([{ type: 'text', text: 'inspect the current session' }])
await waitForIdle(ctx, agent)
const result = findEvent(events(agent), 'tool/result')
expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`)
expect(existsSync(location!.path)).toBe(true)
const header = JSON.parse(readFileSync(location!.path, 'utf8').split('\n')[0]!) as { type: string; id: string }
expect(header).toMatchObject({ type: 'session', id: 'session-env-id' })
await handle.dispose()
})
it('foreground: model calls bash, sees the result, replies', async () => {
const adapter = new MockAdapter([
toolCallResponse('call-1', 'bash', { command: 'echo integration-ok', description: 'test command' }, 'Running it.'),
+134 -8
View File
@@ -10,6 +10,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
@@ -101,6 +103,7 @@ class RecordingSandboxExecutor extends BashExecutor {
return {
command: request.command,
workdir: request.workdir ?? process.cwd(),
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
timeoutMs: request.timeoutMs ?? 1000,
...request.signal ? { signal: request.signal } : {},
sandboxMode: request.sandboxMode ?? 'read-only',
@@ -140,7 +143,13 @@ class CountingStartExecutor extends BashExecutor {
starts = 0
resolve(request: BashExecRequest): BashExecSpec {
return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0, sandboxMode: request.sandboxMode }
return {
command: request.command,
workdir: request.workdir ?? '/x',
timeoutMs: request.timeoutMs ?? 0,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
sandboxMode: request.sandboxMode,
}
}
run(): Promise<BashRunResult> { return Promise.reject(new Error('unused')) }
@@ -924,14 +933,17 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
})
describe('the model-facing bash tool builds its request from named args only (no {...args} forward)', () => {
const recordingDshHome = join(spillDir, 'dsh-home')
/**
* Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a
* test can assert what the model-facing tool DID and DID NOT forward. The `bash`
* tool does not expose `stdin`/`env` as parameters (bash syntax already gives a
* model that power), so it must build its request from named args only and
* tool does not expose trusted-plugin fields (`stdoutMaxBytes`, `stdin`, or
* `env`) as parameters, so it must build its request from named args only and
* never spread unknown tool-call keys into it. This guard's job is to catch a
* future refactor that blindly forwards `...args` which would silently thread
* model input into the post-scrub `env` merge NOT to defend a trust boundary
* model input into the post-scrub `env` merge or per-run capture budget NOT
* to defend a trust boundary
* (the credential scrub in dsh-bash-local is the security control; see the
* bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()`
* hands back an already-settled fake handle so the task registration completes.
@@ -944,9 +956,11 @@ describe('the model-facing bash tool builds its request from named args only (no
command: request.command,
workdir: request.workdir ?? process.cwd(),
timeoutMs: request.timeoutMs ?? 0,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
...request.signal ? { signal: request.signal } : {},
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
sandboxMode: request.sandboxMode,
}
}
@@ -968,19 +982,127 @@ describe('the model-facing bash tool builds its request from named args only (no
}
}
async function setupRecording() {
async function setupRecording(withJsonl = false) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
if (withJsonl) {
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root: join(spillDir, 'jsonl') })
}
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(RecordingBashExecutor)
await ctx.plugin(ToolBash)
await ctx.plugin(ToolBash, { dshHome: recordingDshHome })
return { ctx, bash: ctx.bash as RecordingBashExecutor }
}
it('does not forward env/stdin even when the model includes them as extra arguments', async () => {
it('describes the managed harness environment namespace to the model', async () => {
const { ctx } = await setupRecording()
const description = ctx.tools.get('bash')?.description ?? ''
expect(description).toContain('$DSH_*')
expect(description).not.toContain('DSH_SESSION_JSONL')
})
it('injects the session id and JSONL target path into a foreground request', async () => {
const { ctx, bash } = await setupRecording(true)
const agent = registerFakeAgent(ctx, 'request-fg', () => undefined)
const path = ctx.sessionPersistence.locate(agent.session.header)?.path
await ctx.tools.execute({
callId: CallId('session-env-fg'),
name: 'bash',
arguments: { command: 'true', description: 'run command' },
agent,
})
expect(bash.requests[0]?.dshEnv).toEqual({
DSH_HOME: recordingDshHome,
DSH_SESSION_ID: 'request-fg',
DSH_SESSION_JSONL: path,
DSH_SHELL: '1',
})
})
it('injects the same trusted variables into a background request without forwarding model env', async () => {
const { ctx, bash } = await setupRecording(true)
const agent = registerFakeAgent(ctx, 'request-bg', () => undefined)
const path = ctx.sessionPersistence.locate(agent.session.header)?.path
await ctx.tools.execute({
callId: CallId('session-env-bg'),
name: 'bash',
arguments: {
command: 'sleep 1',
description: 'run command',
run_in_background: true,
env: { DSH_SESSION_ID: 'spoofed', DSH_SESSION_JSONL: '/tmp/spoofed' },
},
agent,
})
expect(bash.requests[0]?.env).toBeUndefined()
expect(bash.requests[0]?.dshEnv).toEqual({
DSH_HOME: recordingDshHome,
DSH_SESSION_ID: 'request-bg',
DSH_SESSION_JSONL: path,
DSH_SHELL: '1',
})
})
it('injects built-ins and the stable session id when no JSONL locator is available', async () => {
const { ctx, bash } = await setupRecording()
const agent = registerFakeAgent(ctx, 'request-id-only', () => undefined)
const ambient = process.env.DSH_SESSION_ID
await ctx.tools.execute({
callId: CallId('session-env-id-only'),
name: 'bash',
arguments: { command: 'true', description: 'run command' },
agent,
})
expect(bash.requests[0]?.dshEnv).toEqual({
DSH_HOME: recordingDshHome,
DSH_SESSION_ID: 'request-id-only',
DSH_SHELL: '1',
})
expect(process.env.DSH_SESSION_ID).toBe(ambient)
})
it('keeps parent and child agent session environments isolated', async () => {
const { ctx, bash } = await setupRecording(true)
const parent = registerFakeAgent(ctx, 'request-parent', () => undefined)
const child = registerFakeAgent(ctx, 'request-child', () => undefined)
for (const [callId, agent] of [['parent', parent], ['child', child]] as const) {
await ctx.tools.execute({
callId: CallId(`session-env-${callId}`),
name: 'bash',
arguments: { command: 'true', description: 'run command' },
agent,
})
}
expect(bash.requests.map(request => request.dshEnv)).toEqual([
{
DSH_HOME: recordingDshHome,
DSH_SESSION_ID: 'request-parent',
DSH_SESSION_JSONL: ctx.sessionPersistence.locate(parent.session.header)?.path,
DSH_SHELL: '1',
},
{
DSH_HOME: recordingDshHome,
DSH_SESSION_ID: 'request-child',
DSH_SESSION_JSONL: ctx.sessionPersistence.locate(child.session.header)?.path,
DSH_SHELL: '1',
},
])
expect(bash.requests[0]?.dshEnv?.DSH_SESSION_JSONL).not.toBe(bash.requests[1]?.dshEnv?.DSH_SESSION_JSONL)
})
it('does not forward trusted-only fields even when the model includes them as extra arguments', async () => {
const { ctx, bash } = await setupRecording()
// Unknown `env` and `stdin` keys are ignored by the schema and named request construction.
// This preserves the request shape; it is not a security boundary because shell syntax can
@@ -993,6 +1115,7 @@ describe('the model-facing bash tool builds its request from named args only (no
description: 'echo',
env: { SNEAKY_API_KEY: 'leak' },
stdin: 'malicious payload',
stdoutMaxBytes: 999_999,
},
})
expect(bash.requests).toHaveLength(1)
@@ -1000,9 +1123,10 @@ describe('the model-facing bash tool builds its request from named args only (no
expect(request.command).toBe('echo hi')
expect('env' in request).toBe(false)
expect('stdin' in request).toBe(false)
expect('stdoutMaxBytes' in request).toBe(false)
})
it('a background bash call likewise carries no env/stdin', async () => {
it('a background bash call likewise carries no trusted-only fields', async () => {
const { ctx, bash } = await setupRecording()
const result = await ctx.tools.execute({
callId: CallId('no-forward-2'),
@@ -1013,6 +1137,7 @@ describe('the model-facing bash tool builds its request from named args only (no
run_in_background: true,
env: { TOKEN: 'leak' },
stdin: 'x',
stdoutMaxBytes: 999_999,
},
})
// The call really went down the background path (the recorder sees the real
@@ -1024,5 +1149,6 @@ describe('the model-facing bash tool builds its request from named args only (no
expect(request.command).toBe('sleep 1')
expect('env' in request).toBe(false)
expect('stdin' in request).toBe(false)
expect('stdoutMaxBytes' in request).toBe(false)
})
})
+6
View File
@@ -26,9 +26,15 @@
{
"path": "../../core/agent"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../bash/bash"
},
{
"path": "../../util/home"
},
{
"path": "../../tasks/tasks"
},
+1 -1
View File
@@ -31,11 +31,11 @@
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
@@ -1,14 +1,12 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
@@ -60,12 +58,8 @@ class StepwiseToolAdapter extends LlmAdapter {
async function harness(toolSteps: number): Promise<{ ctx: Context; compact: ReproCompactService }> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(Invariants)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
ctx.tools.register(defineTool({
+2 -2
View File
@@ -1,10 +1,10 @@
# context/ — request-context extensions
Product plugins that add bounded model-visible request context without defining a tool or service. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in.
Product plugins that add model-visible request context without defining a tool or service. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in.
| Package | Role | ctx key |
|---|---|---|
| `time-context/` | Current time and elapsed-time system-prompt context | (none) |
| `time-context/` | Durable per-step current time and elapsed-time context | (none) |
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) |
The [`workspace-context` decision record](../../docs/rfc/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split.
+31 -16
View File
@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-time-context
Opt-in dynamic system-prompt context with the current zoned time and elapsed time since the latest model-visible message before the turn. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the time-context RFC](../../../docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md).
Opt-in durable context with the current zoned time and elapsed time sampled during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the durable time-context RFC](../../../docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.md).
## Config
@@ -8,36 +8,51 @@ Opt-in dynamic system-prompt context with the current zoned time and elapsed tim
- id: time-context
name: '@deepseek-ai/dsh-time-context'
config:
timeZone: Asia/Shanghai # optional IANA override; omit for the process zone
refreshIntervalMs: 60000 # default; 0 refreshes on every step
timeZone: Asia/Shanghai # optional IANA override; omit for the process zone
refreshIntervalMs: 60000 # optional; omit or set to 0 for every eligible attempt
```
When `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit `timeZone` must be an IANA identifier and is validated at plugin load. `refreshIntervalMs` must be a non-negative safe integer. Every turn's first request refreshes; later steps reuse the reading until its age reaches the interval. `0` refreshes every step. Refresh occurs only during request assembly and creates no timer work.
When `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit `timeZone` must be an IANA identifier and is validated at plugin load.
## Message baseline
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` appends on every pre-step attempt whose signal is not already aborted. A positive value appends only when the session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds have elapsed since the latest injection.
The duration starts at the latest user, assistant, tool-result, context, or steering message before the current `turn/start`. Every refresh in the turn retains that baseline, so the current prompt does not collapse the interval to approximately zero. The first turn reports that no earlier message exists. The durable clock source is session-event append time, not client send time.
## Timing semantics
The loop records the dynamic section in `request/header` / `request/header-delta`. Requests therefore remain reconstructable, carry one timing block, and retain no earlier readings in conversation history.
The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one `context/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing.
Positive-interval scheduling scans the raw durable session events for the latest `context/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently.
Step 1 measures from the latest preceding model-visible message, including the prompt that opened the turn. Later steps measure from the preceding time-context event in the same turn. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline, or a later step with no earlier same-turn reading because interval suppression skipped it, reports `unavailable`.
A time reading records a request-preparation attempt, not a committed step or transmitted request. Because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt; the log is append-only and the plugin performs no rollback.
The time reading stays in derived conversation history until a later compaction shadows it. Request headers and header deltas contain no time-context state. Request reconstruction uses the complete durable surface prefix at each `step/start`, so transmitted requests need not map one-to-one to readings: a failed preparation can leave an extra reading, while interval suppression can let a request reuse existing history without adding one.
## Model Experience
### Temporal system prompt
### Preparation-time temporal context
**What the model sees**: Every request in an active turn includes the two lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; `<duration-or-unavailable>` is compact whole-second units or the first-turn fallback.
**What the model sees**: On each preparation attempt that injects, one source-tagged context message containing the two lines below. `<timestamp>` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. Positive intervals can leave an attempted step without a new reading.
**Token effect**: Fixed two-line cost per request. A refresh replaces the request-header section; prior readings do not accumulate.
**Token effect**: Each injected two-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every eligible preparation attempt.
#### Temporal context section
#### First step
```markdown
Current time: <timestamp>
Time since previous message: <duration-or-unavailable>.
Time sampled while preparing turn <turn>, step 1: <timestamp>
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
```
#### Later steps
```markdown
Time sampled while preparing turn <turn>, step <step>: <timestamp>
Elapsed since the preceding step context: <duration-or-unavailable>.
```
## Known Limitations and Deferred Work
- **Request-bound refresh only** — no clock update is emitted while the agent is waiting inside a model call or tool; the next assembled step refreshes once the configured interval has elapsed.
- **Whole-second display** timestamps and durations omit sub-second precision even when `refreshIntervalMs` is below 1,000.
- **Session-event baseline** — elapsed time starts from the durable append timestamp, not a client transport's original send timestamp.
- **Whole-second display** — timestamps and durations omit sub-second precision even though durable event times retain milliseconds.
- **Session-event baseline** — elapsed time starts from durable append timestamps, not a client transport's original send timestamp.
- **Process-local default zone** — omission uses the Node process's `TZ`, host, or container zone captured at plugin load, not a remote user's zone; configure an explicit IANA zone when those differ.
- **History cost between compactions** — omission or `0` retains one reading for every eligible preparation attempt, including attempts later cancelled or failed; a positive interval reduces but does not eliminate this cost.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-time-context",
"description": "Opt-in system-prompt context with the current time and elapsed time since the previous message",
"description": "Opt-in durable per-step context with the current time and elapsed time",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -26,12 +26,12 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
+100 -98
View File
@@ -1,8 +1,6 @@
/**
* Opt-in request-time clock context. Active turns receive the current zoned
* time and elapsed time since the preceding model-visible message. The loop
* logs each rendered value as request-header state rather than conversation
* history.
* Opt-in request-preparation clock context. Eligible pre-step attempts append
* durable, source-attributed time readings to conversation history.
*
* @module @deepseek-ai/dsh-time-context
*/
@@ -10,77 +8,30 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
import type { Message } from '@deepseek-ai/dsh-llm'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'time-context'
/** The system-prompt registry that owns the dynamic request section. */
export const inject = ['systemPrompt']
/** The agent registry that owns the pre-step lifecycle seam. */
export const inject = ['agents']
/** Request-time clock formatting and refresh policy. Invalid values fail plugin load. */
/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
export interface Config {
/** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */
timeZone?: string
/** Maximum age of a reading within one turn, in milliseconds (default 60,000; `0` refreshes every step). */
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible pre-step attempt. */
refreshIntervalMs?: number
}
/** Schemastery validation and defaults for {@link Config}. */
/** Schemastery validation for {@link Config}. */
export const Config: z<Config> = z.object({
timeZone: z.string(),
refreshIntervalMs: z.number().default(60_000),
refreshIntervalMs: z.number(),
})
interface OpenTurn {
turn: number
startSeq: number
}
/** Cached text and the fixed inter-turn baseline used by one agent's open turn. */
interface RenderState {
turn: number
renderedAt: number
previousMessageTime: number | undefined
text: string
}
type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year'
function openTurn(agent: Agent): OpenTurn | undefined {
for (const event of [...agent.session.events].reverse()) {
switch (event.type) {
case 'turn/end':
return undefined
case 'turn/start':
return { turn: event.data.turn, startSeq: event.seq }
default:
// Merge-extensible session events: only turn boundaries matter here.
break
}
}
return undefined
}
/** Find the latest model-visible timestamp strictly before one turn boundary. */
function previousMessageTime(agent: Agent, turnStartSeq: number): number | undefined {
for (const event of [...agent.session.events].reverse()) {
if (event.seq >= turnStartSeq) continue
switch (event.type) {
case 'user/message':
case 'assistant/message':
case 'tool/result':
case 'context/message':
case 'steering/message':
return event.time
default:
// Merge-extensible session events: non-surface records are not messages.
break
}
}
return undefined
}
/** Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone. */
function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string {
const parts = Object.fromEntries(
@@ -107,31 +58,85 @@ function formatDuration(elapsedMs: number): string {
return parts.join(' ')
}
/** Find the latest model-visible event, excluding this plugin's pending append. */
function precedingMessageTime(agent: Agent): number | undefined {
for (const event of [...agent.session.events].reverse()) {
switch (event.type) {
case 'user/message':
case 'assistant/message':
case 'tool/result':
case 'context/message':
case 'steering/message':
return event.time
default:
// Merge-extensible session events: non-surface records are not messages.
break
}
}
return undefined
}
/** Find the preceding time-context event within the open turn. */
function precedingStepContextTime(agent: Agent, turn: number): number | undefined {
for (const event of [...agent.session.events].reverse()) {
if (event.type === 'turn/start' && event.data.turn === turn) return undefined
if (event.type === 'context/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === name) {
return event.time
}
}
return undefined
}
/** Find this plugin's latest durable injection, including a shadowed surface event. */
function latestInjectionTime(agent: Agent): number | undefined {
for (const event of [...agent.session.events].reverse()) {
if (event.type === 'context/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === name) {
return event.time
}
}
return undefined
}
function renderText(
now: number,
turn: number,
step: number,
previous: number | undefined,
formatter: Intl.DateTimeFormat,
timeZone: string,
): string {
const elapsed = previous === undefined
? 'unavailable (no earlier message in this session)'
: formatDuration(now - previous)
return `Current time: ${formatTimestamp(now, formatter, timeZone)}\nTime since previous message: ${elapsed}.`
const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous)
const baseline = step === 1 ? 'model-visible message' : 'step context'
return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n`
+ `Elapsed since the preceding ${baseline}: ${elapsed}.`
}
/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
if (refreshIntervalMs !== undefined && (
!Number.isSafeInteger(refreshIntervalMs)
|| refreshIntervalMs < 0
)) {
throw new TypeError(
`time-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`,
)
}
}
/**
* Register the request-time clock section for the lifetime of `ctx`.
* @param ctx - plugin context; the section registration is disposed with it.
* @param config - validated time zone and intra-turn refresh interval.
* @throws when the time zone or refresh interval is invalid.
* Register a prepended pre-step listener for the lifetime of `ctx`.
* @param ctx - plugin context; the listener is disposed with it.
* @param config - time zone and durable refresh scheduling configuration.
* @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved.
*/
export function apply(ctx: Context, config: Config): void {
const timeZone = config.timeZone
const refreshIntervalMs = config.refreshIntervalMs as number
if (!Number.isSafeInteger(refreshIntervalMs) || refreshIntervalMs < 0) {
throw new Error(`time-context: refreshIntervalMs must be a non-negative safe integer, got ${refreshIntervalMs}`)
}
const refreshIntervalMs = config.refreshIntervalMs
validateRefreshInterval(refreshIntervalMs)
let formatter: Intl.DateTimeFormat
try {
formatter = new Intl.DateTimeFormat('en-US', {
@@ -152,32 +157,29 @@ export function apply(ctx: Context, config: Config): void {
throw new Error(message, { cause: error })
}
const resolvedTimeZone = formatter.resolvedOptions().timeZone
const states = new WeakMap<Agent, RenderState>()
ctx.systemPrompt.section({
name: 'context:time',
order: 10,
text(context: AssembleContext): string {
const agent = context.agent
if (agent === undefined) return ''
const currentTurn = openTurn(agent)
if (currentTurn === undefined) return ''
const now = Date.now()
const prior = states.get(agent)
if (prior !== undefined
&& prior.turn === currentTurn.turn
&& now >= prior.renderedAt
&& now - prior.renderedAt < refreshIntervalMs) {
return prior.text
}
const previous = prior?.turn === currentTurn.turn
? prior.previousMessageTime
: previousMessageTime(agent, currentTurn.startSeq)
const text = renderText(now, previous, formatter, resolvedTimeZone)
states.set(agent, { turn: currentTurn.turn, renderedAt: now, previousMessageTime: previous, text })
return text
},
})
ctx.on('agent/pre-step', (
agent: Agent,
turn: number,
step: number,
_fullSystemPrompt: string,
_sessionPrefix: readonly Message[],
signal: AbortSignal,
) => {
if (signal.aborted) return
const now = Date.now()
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) {
const lastInjection = latestInjectionTime(agent)
if (lastInjection !== undefined
&& now >= lastInjection
&& now - lastInjection < refreshIntervalMs) return
}
const previous = step === 1
? precedingMessageTime(agent)
: precedingStepContextTime(agent, turn)
agent.inject(
[{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }],
{ source: { kind: 'plugin', plugin: name } },
)
}, { prepend: true })
}
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { foldRequestHeader, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('./fixtures/cordis.yml', import.meta.url))
@@ -12,7 +12,8 @@ const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.m
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
const PROCESS_TIMEOUT_MS = 30_000
const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
const FIRST_REPLY = 'You said: "first". Try "echo <something>" to see a tool call.'
const FIRST_REPLY = '[main turn 1] You said: "Time sampled while preparing turn 1, step 1:'
const SECOND_REPLY = '[main turn 2] You said: "Time sampled while preparing turn 2, step 1:'
let child: ChildProcessWithoutNullStreams | undefined
let workdir: string | undefined
@@ -60,7 +61,7 @@ async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> {
proc.stdout.setEncoding('utf8')
proc.stdout.on('data', (chunk: string) => {
stdout += chunk
if (!sentSecond && stdout.includes(`${FIRST_REPLY}\n> `)) {
if (!sentSecond && stdout.includes(FIRST_REPLY) && stdout.includes('Try "echo <something>" to see a tool call.\n> ')) {
sentSecond = true
proc.stdin.end('second\n')
}
@@ -84,12 +85,12 @@ async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> {
}
describe('time-context through a real cordis.yml and stdio process', () => {
it('uses the process zone and persists both first-turn and elapsed-time request context', async () => {
it('uses the process zone and persists one ordered context event per request', async () => {
const { stdout, stderr } = await runTwoTurns()
expect(stderr).not.toContain('UNHANDLED')
expect(stdout).toContain('time-context e2e ready.')
expect(stdout).toContain(FIRST_REPLY)
expect(stdout).toContain('You said: "second".')
expect(stdout).toContain(SECOND_REPLY)
const logs = await jsonlFiles(join(workdir as string, '.sessions'))
expect(logs).toHaveLength(1)
@@ -97,19 +98,29 @@ describe('time-context through a real cordis.yml and stdio process', () => {
const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2)
const firstHeader = events.find(event => event.type === 'request/header')
if (firstHeader?.type !== 'request/header') throw new Error('missing initial request/header event')
expect(firstHeader.data.header.system).toMatch(
/Current time: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/,
const contexts = events.filter(event => event.type === 'context/message')
const starts = events.filter(event => event.type === 'step/start')
expect(contexts).toHaveLength(2)
expect(starts).toHaveLength(2)
for (let index = 0; index < contexts.length; index += 1) {
expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq)
expect(contexts[index]!.surfaceOp).toBe('append')
expect(contexts[index]!.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
}
const contextText = contexts.map(event => event.data.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n'))
expect(contextText[0]).toMatch(
/Time sampled while preparing turn 1, step 1: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/,
)
expect(firstHeader.data.header.system).toContain(
'Time since previous message: unavailable (no earlier message in this session).',
expect(contextText[0]).toMatch(
/Elapsed since the preceding model-visible message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./,
)
expect(contextText[1]).toMatch(/Time sampled while preparing turn 2, step 1:/)
const finalSystem = foldRequestHeader(events)?.system
expect(finalSystem).toContain('[Asia/Shanghai]')
expect(finalSystem).toMatch(
/Time since previous message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./,
)
const headers = events.filter(event => event.type === 'request/header'
|| event.type === 'request/header-delta')
expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing')
}, TEST_TIMEOUT_MS)
})
@@ -1,19 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import { defineTool } from '@deepseek-ai/dsh-tools'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as timeContext from '@deepseek-ai/dsh-time-context'
import type { Config } from '@deepseek-ai/dsh-time-context'
const BASE = Date.parse('2026-07-14T00:00:00.000Z')
const ORIGINAL_TIME_ZONE = process.env['TZ']
const SIGNAL = new AbortController().signal
beforeEach(() => {
process.env['TZ'] = 'UTC'
@@ -30,18 +31,29 @@ afterEach(() => {
async function mount(config: Config = {}) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(timeContext, config)
return { ctx, fiber }
}
function sessionAgent(session: Session, id = 'agent'): Agent {
return { id: AgentId(id), session } as unknown as Agent
}
async function sectionText(ctx: Context, agent?: Agent): Promise<string | undefined> {
const assembly = await ctx.systemPrompt.assemble(agent === undefined ? {} : { agent })
return assembly.sections.find(section => section.name === 'context:time')?.text
return {
id: AgentId(id),
options: {},
session,
status: 'running',
ctx: new Context(),
send() {},
steer() {},
inject(content, options) {
session.append('context/message', {
content,
source: options?.source ?? { kind: 'user' },
}, { surfaceOp: 'append' })
},
cancel() {},
whenIdle: () => Promise.resolve(),
}
}
function openMessageTurn(session: Session, turn: number): void {
@@ -52,6 +64,28 @@ function openMessageTurn(session: Session, turn: number): void {
}, { surfaceOp: 'append' })
}
function contextTexts(session: Session): string[] {
const texts: string[] = []
for (const event of session.events) {
if (event.type === 'context/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'time-context') {
texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
}
}
return texts
}
async function fire(
ctx: Context,
agent: Agent,
turn: number,
step: number,
signal: AbortSignal = SIGNAL,
): Promise<void> {
await ctx.serial('agent/pre-step', agent, turn, step, '', [], signal)
}
function textResponse(text: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
@@ -89,184 +123,186 @@ class ScriptedAdapter extends LlmAdapter {
async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(timeContext, config)
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
describe('temporal section rendering', () => {
it('renders the first turn in UTC with the explicit no-previous-message fallback', async () => {
const { ctx } = await mount()
function requestText(request: GenerateOptions): string {
return request.messages
.flatMap(message => message.content)
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n')
}
describe('durable step context', () => {
it('records turn, step, zoned time, and the preceding model-visible message baseline', async () => {
const { ctx } = await mount({ timeZone: 'Asia/Shanghai' })
const session = new Session(SessionId('first'))
openMessageTurn(session, 1)
expect(await sectionText(ctx, sessionAgent(session))).toBe(
'Current time: 2026-07-14T00:00:00+00:00[UTC]\n'
+ 'Time since previous message: unavailable (no earlier message in this session).',
)
})
it('renders a non-UTC numeric offset and all compact duration units', async () => {
const { ctx } = await mount({ timeZone: 'Asia/Shanghai' })
const session = new Session(SessionId('offset'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'text', text: 'previous' }],
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
vi.setSystemTime(BASE + 90_061_000)
openMessageTurn(session, 2)
expect(await sectionText(ctx, sessionAgent(session))).toBe(
'Current time: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n'
+ 'Time since previous message: 1d 1h 1m 1s.',
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)).toEqual([
'Time sampled while preparing turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n'
+ 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
])
const event = session.events.at(-1)
expect(event?.type).toBe('context/message')
if (event?.type !== 'context/message') throw new Error('missing time context')
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' })
expect(event.surfaceOp).toBe('append')
})
it('reports an unavailable first-step baseline when no model-visible message precedes it', async () => {
const { ctx } = await mount()
const session = new Session(SessionId('unavailable'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)[0]).toContain(
'Elapsed since the preceding model-visible message: unavailable.',
)
})
it('clamps a backward wall-clock adjustment to a zero duration', async () => {
it.each([
['omitted interval', {}],
['zero interval', { refreshIntervalMs: 0 }],
] as const)('uses the preceding durable step-context timestamp after step one with %s', async (_label, config) => {
const { ctx } = await mount(config)
const session = new Session(SessionId('later-step'))
const agent = sessionAgent(session)
openMessageTurn(session, 3)
await fire(ctx, agent, 3, 1)
vi.setSystemTime(BASE + 61_000)
await fire(ctx, agent, 3, 2)
expect(contextTexts(session)[1]).toBe(
'Time sampled while preparing turn 3, step 2: 2026-07-14T00:01:01+00:00[UTC]\n'
+ 'Elapsed since the preceding step context: 1m 1s.',
)
})
it('reports an unavailable later-step baseline at the matching turn boundary', async () => {
const { ctx } = await mount()
const session = new Session(SessionId('backward-duration'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'text', text: 'future by adjusted clock' }],
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const session = new Session(SessionId('later-step-boundary'))
openMessageTurn(session, 4)
await fire(ctx, sessionAgent(session), 4, 2)
expect(contextTexts(session)[0]).toContain(
'Elapsed since the preceding step context: unavailable.',
)
})
it('reports an unavailable later-step baseline when event lookup is exhausted', async () => {
const { ctx } = await mount()
const session = new Session(SessionId('later-step-exhausted'))
await fire(ctx, sessionAgent(session), 1, 2)
expect(contextTexts(session)[0]).toContain(
'Elapsed since the preceding step context: unavailable.',
)
})
it('injects after backward wall-clock movement and clamps elapsed time to zero', async () => {
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
const session = new Session(SessionId('backward'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
await fire(ctx, agent, 1, 1)
vi.setSystemTime(BASE - 5_000)
openMessageTurn(session, 2)
expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 0s.')
await fire(ctx, agent, 1, 2)
expect(contextTexts(session)).toHaveLength(2)
expect(contextTexts(session)[1]).toContain('Elapsed since the preceding step context: 0s.')
})
const previousMessageCases = [
['user/message', (session: Session): void => {
session.append('user/message', { content: [{ type: 'text', text: 'u' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
}],
['assistant/message', (session: Session): void => {
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' })
}],
['tool/result', (session: Session): void => {
session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('previous'),
content: [{ type: 'text', text: 'r' }],
isError: false,
}, { surfaceOp: 'append' })
}],
['context/message', (session: Session): void => {
session.append('context/message', {
content: [{ type: 'text', text: 'c' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })
}],
['steering/message', (session: Session): void => {
session.append('steering/message', {
turn: 1,
content: [{ type: 'text', text: 's' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}],
] as const
it('uses a shadowed durable injection after resume and injects at the exact threshold', async () => {
const { ctx } = await mount({ refreshIntervalMs: 1_000 })
const original = new Session(SessionId('seed-source'))
openMessageTurn(original, 1)
await fire(ctx, sessionAgent(original), 1, 1)
const user = original.events.find(event => event.type === 'user/message')
const reading = original.events.find(event => event.type === 'context/message')
if (user === undefined || reading === undefined) throw new Error('missing source surface events')
original.append('context/message', {
content: [{ type: 'text', text: 'compacted history' }],
source: { kind: 'plugin', plugin: 'compact-basic' },
}, {
surfaceOp: { op: 'replace', start: user.seq, end: reading.seq },
sourceEventSeqs: [user.seq, reading.seq],
})
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing')
it.each(previousMessageCases)('uses a prior %s as the duration baseline', async (_name, appendPrevious) => {
const { ctx } = await mount()
const session = new Session(SessionId(`previous-${_name}`))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
appendPrevious(session)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
vi.setSystemTime(BASE + 5_000)
openMessageTurn(session, 2)
const resumed = new Session(SessionId('resumed'), [...original.events])
const resumedAgent = sessionAgent(resumed)
vi.setSystemTime(BASE + 999)
openMessageTurn(resumed, 2)
const beforeSkip = resumed.events.length
expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 5s.')
})
await fire(ctx, resumedAgent, 2, 1)
it('contributes empty text without an active agent turn', async () => {
const { ctx } = await mount()
expect(await sectionText(ctx)).toBe('')
expect(resumed.events).toHaveLength(beforeSkip)
expect(contextTexts(resumed)).toHaveLength(1)
const empty = sessionAgent(new Session(SessionId('empty')))
expect(await sectionText(ctx, empty)).toBe('')
const closedSession = new Session(SessionId('closed'))
openMessageTurn(closedSession, 1)
closedSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(await sectionText(ctx, sessionAgent(closedSession))).toBe('')
})
})
describe('refresh policy', () => {
it('reuses within the interval, refreshes at expiry, and refreshes after a backward clock jump', async () => {
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
const session = new Session(SessionId('interval'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
const first = await sectionText(ctx, agent)
vi.setSystemTime(BASE + 30_000)
expect(await sectionText(ctx, agent)).toBe(first)
vi.setSystemTime(BASE + 60_000)
const expired = await sectionText(ctx, agent)
expect(expired).toContain('2026-07-14T00:01:00+00:00[UTC]')
vi.setSystemTime(BASE + 59_000)
expect(await sectionText(ctx, agent)).toContain('2026-07-14T00:00:59+00:00[UTC]')
})
it('refreshes every assembly when refreshIntervalMs is zero', async () => {
const { ctx } = await mount({ refreshIntervalMs: 0 })
const session = new Session(SessionId('every-step'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
const first = await sectionText(ctx, agent)
vi.setSystemTime(BASE + 1_000)
expect(await sectionText(ctx, agent)).not.toBe(first)
await fire(ctx, resumedAgent, 2, 2)
expect(contextTexts(resumed)).toHaveLength(2)
expect(contextTexts(resumed)[1]).toContain(
'Elapsed since the preceding step context: unavailable.',
)
})
it('always refreshes for a new turn and keeps the preceding message baseline', async () => {
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
const session = new Session(SessionId('turn-refresh'))
it('applies a positive interval across turns without sharing state between sessions', async () => {
const { ctx } = await mount({ refreshIntervalMs: 1_000 })
const first = new Session(SessionId('interval-first'))
const firstAgent = sessionAgent(first, 'first-agent')
openMessageTurn(first, 1)
await fire(ctx, firstAgent, 1, 1)
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
vi.setSystemTime(BASE + 500)
openMessageTurn(first, 2)
const beforeSkip = first.events.length
await fire(ctx, firstAgent, 2, 1)
const independent = new Session(SessionId('interval-independent'))
openMessageTurn(independent, 1)
await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1)
expect(first.events).toHaveLength(beforeSkip)
expect(contextTexts(first)).toHaveLength(1)
expect(contextTexts(independent)).toHaveLength(1)
})
it('runs before ordinary pre-step listeners and skips an already-aborted step', async () => {
const { ctx } = await mount()
const session = new Session(SessionId('ordering'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
const first = await sectionText(ctx, agent)
vi.setSystemTime(BASE + 1_000)
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'text', text: 'done' }],
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
vi.setSystemTime(BASE + 2_000)
openMessageTurn(session, 2)
let ordinarySawContext = false
ctx.on('agent/pre-step', (subject) => {
ordinarySawContext = subject.session.events.some(event => event.type === 'context/message')
})
const second = await sectionText(ctx, agent)
expect(second).not.toBe(first)
expect(second).toContain('Time since previous message: 1s.')
})
await fire(ctx, agent, 1, 1)
const abort = new AbortController()
abort.abort()
await fire(ctx, agent, 1, 2, abort.signal)
it('keeps refresh caches independent per agent', async () => {
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
const sessionA = new Session(SessionId('agent-a'))
const sessionB = new Session(SessionId('agent-b'))
const agentA = sessionAgent(sessionA, 'a')
const agentB = sessionAgent(sessionB, 'b')
openMessageTurn(sessionA, 1)
openMessageTurn(sessionB, 1)
const aFirst = await sectionText(ctx, agentA)
vi.setSystemTime(BASE + 30_000)
const bFirst = await sectionText(ctx, agentB)
vi.setSystemTime(BASE + 40_000)
expect(await sectionText(ctx, agentA)).toBe(aFirst)
expect(bFirst).toContain('2026-07-14T00:00:30+00:00[UTC]')
expect(ordinarySawContext).toBe(true)
expect(contextTexts(session)).toHaveLength(1)
})
})
@@ -278,49 +314,79 @@ describe('configuration and lifecycle', () => {
const session = new Session(SessionId('system-zone'))
openMessageTurn(session, 1)
expect(await sectionText(ctx, sessionAgent(session))).toContain(
'Current time: 2026-07-14T08:00:00+08:00[Asia/Shanghai]',
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)[0]).toContain('2026-07-14T08:00:00+08:00[Asia/Shanghai]')
})
it('fails loud for an invalid explicit zone or an unavailable process zone', async () => {
const invalid = new Context()
await invalid.plugin(AgentRegistry)
await expect(invalid.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow(
/invalid IANA timeZone/,
)
})
it('fails loud for negative, fractional, unsafe, and invalid-zone config', async () => {
for (const refreshIntervalMs of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await expect(ctx.plugin(timeContext, { refreshIntervalMs })).rejects.toThrow(/non-negative safe integer/)
}
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await expect(ctx.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow(/invalid IANA timeZone/)
})
it('fails loud when the process system zone cannot be resolved', async () => {
vi.spyOn(Intl, 'DateTimeFormat').mockImplementationOnce(() => {
throw new RangeError('system zone unavailable')
})
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await expect(ctx.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/)
const unresolved = new Context()
await unresolved.plugin(AgentRegistry)
await expect(unresolved.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/)
})
it('removes its section when the plugin fiber disposes', async () => {
it('rejects invalid refresh intervals at plugin load with one diagnostic', async () => {
const invalid = [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, Number.POSITIVE_INFINITY, Number.NaN]
for (const refreshIntervalMs of invalid) {
await expect(mount({ refreshIntervalMs })).rejects.toThrow(
'time-context: refreshIntervalMs must be a non-negative safe integer',
)
}
})
it('removes its listener when the plugin fiber disposes', async () => {
const { ctx, fiber } = await mount()
const session = new Session(SessionId('dispose'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
expect(await sectionText(ctx, agent)).toContain('Current time:')
await fire(ctx, agent, 1, 1)
await fiber.dispose()
expect(await sectionText(ctx, agent)).toBeUndefined()
await fire(ctx, agent, 1, 2)
expect(contextTexts(session)).toHaveLength(1)
})
})
describe('real agent-loop request logging', () => {
it('refreshes a long turn in the system prompt and records the header delta without context history', async () => {
const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done'), textResponse('next turn')])
const ctx = await loopHarness(adapter, { refreshIntervalMs: 60_000 })
describe('real agent-loop request history', () => {
it.each([
['throws', 'error'],
['cancels', 'aborted'],
] as const)('retains the preparation reading when a later pre-step listener %s', async (mode, reasonKind) => {
const adapter = new ScriptedAdapter([textResponse('unused')])
const ctx = await loopHarness(adapter)
let laterSawReading = false
ctx.on('agent/pre-step', (subject) => {
laterSawReading = contextTexts(subject.session).length === 1
if (mode === 'throws') throw new Error('later pre-step failure')
subject.cancel('later pre-step cancellation')
})
const agent = ctx.agentLoop.create(AgentId(`late-${mode}`), { model: 'mock' })
agent.send([{ type: 'text', text: 'start' }])
await agent.whenIdle()
expect(laterSawReading).toBe(true)
expect(contextTexts(agent.session)).toHaveLength(1)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe(reasonKind)
await ctx.fiber.dispose()
})
it('persists one ordered context per request, accumulates readings, and leaves system headers unchanged', async () => {
const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done')])
const ctx = await loopHarness(adapter)
ctx.tools.register(defineTool({
name: 'tick',
description: 'advance fake time',
@@ -334,38 +400,55 @@ describe('real agent-loop request logging', () => {
agent.send([{ type: 'text', text: 'start' }])
await agent.whenIdle()
expect(adapter.requests).toHaveLength(2)
expect(adapter.requests[0]!.system).toContain('2026-07-14T00:00:00+00:00[UTC]')
expect(adapter.requests[1]!.system).toContain('2026-07-14T00:01:01+00:00[UTC]')
expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false)
expect(agent.session.events.filter(event => event.type === 'request/header-delta')).toHaveLength(1)
expect(foldRequestHeader(agent.session.events)?.system).toBe(adapter.requests[1]!.system)
vi.setSystemTime(BASE + 361_000)
agent.send([{ type: 'text', text: 'again' }])
await agent.whenIdle()
expect(adapter.requests[2]!.system).toContain('Time since previous message: 5m 0s.')
expect(adapter.requests).toHaveLength(2)
const contexts = agent.session.events.filter(event => event.type === 'context/message')
const starts = agent.session.events.filter(event => event.type === 'step/start')
expect(contexts).toHaveLength(adapter.requests.length)
expect(starts).toHaveLength(adapter.requests.length)
for (let index = 0; index < contexts.length; index += 1) {
expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq)
}
expect(contexts.every(event => event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'time-context'
&& event.surfaceOp === 'append')).toBe(true)
const firstRequestText = requestText(adapter.requests[0]!)
const secondRequestText = requestText(adapter.requests[1]!)
expect(firstRequestText).toContain('Time sampled while preparing turn 1, step 1:')
expect(firstRequestText).toContain('Elapsed since the preceding model-visible message: 0s.')
expect(firstRequestText).not.toContain('Time sampled while preparing turn 1, step 2:')
expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 1:')
expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 2:')
expect(secondRequestText).toContain('Elapsed since the preceding step context: 1m 1s.')
for (const request of adapter.requests) expect(request.system).not.toContain('Time sampled while preparing')
const headers = agent.session.events.filter(event => event.type === 'request/header'
|| event.type === 'request/header-delta')
expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing')
expect(agent.session.events.filter(event => event.type === 'request/header-delta')).toHaveLength(0)
await ctx.fiber.dispose()
})
})
describe('real Loader export path', () => {
it('keeps the namespace metadata and boots through unwrapExports', async () => {
it('keeps namespace metadata and boots the agent listener through unwrapExports', async () => {
expect('default' in timeContext).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(timeContext) as Record<string, unknown>
expect(unwrapped).toBe(timeContext)
expect(unwrapped.name).toBe('time-context')
expect(unwrapped.inject).toEqual(['systemPrompt'])
expect(unwrapped.inject).toEqual(['agents'])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(AgentRegistry)
const plugin = loader.unwrapExports(timeContext) as Parameters<Context['plugin']>[0]
await ctx.plugin(plugin)
const session = new Session(SessionId('loader'))
openMessageTurn(session, 1)
expect(await sectionText(ctx, sessionAgent(session))).toContain('Current time:')
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)[0]).toContain('Time sampled while preparing turn 1, step 1:')
})
})
+1 -1
View File
@@ -9,7 +9,7 @@
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../core/system-prompt" },
{ "path": "../../llm/llm" },
{ "path": "../../core/agent" }
]
}
@@ -2182,7 +2182,10 @@ describe('dynamic nested workspace context injection', () => {
agent.session.append('user/message', {
content: [{ type: 'text', text: 'compacted summary' }],
source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: contextSeq, end: contextSeq } })
}, {
surfaceOp: { op: 'replace', start: contextSeq, end: contextSeq },
sourceEventSeqs: [contextSeq],
})
const afterCompact = await ctx.tools.execute({
callId: CallId('read-after-compact'),
+1
View File
@@ -33,6 +33,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
+82 -3
View File
@@ -91,6 +91,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
'abstract start(spec: BashExecSpec): BashProcess',
],
},
{
key: 'bashEnv',
summary: 'Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables.',
methods: [
'register(contributor: BashEnvContributor): () => void',
'collect(execution: ToolExecution): DshEnvironment',
'list(): BashEnvVariableInfo[]',
],
},
{
key: 'codeRuntime',
summary: 'Registers one `ctx.codeRuntime` implementation.',
@@ -150,6 +159,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
key: 'sessionPersistence',
summary: 'Durable append-only session storage.',
methods: [
'abstract locate(meta: SessionHeader): SessionLocation | undefined',
'abstract create(meta: SessionHeader): Promise<void>',
'abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void>',
'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>',
@@ -158,10 +168,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'sessionQuery',
summary: 'Live-preferred logical-corpus and exact-event read service.',
summary: 'Live-preferred logical-corpus exact-read and relationship-tracing service.',
methods: [
'listSessions(): Promise<SessionRecord[]>',
'async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]>',
'async traceSession(sessionId: SessionId): Promise<SessionLineageTrace>',
'async traceEvent(request: SessionEventTraceRequest): Promise<SessionEventTrace>',
'async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow>',
],
},
@@ -189,6 +201,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
'async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>',
],
},
{
key: 'spillStore',
summary: 'Abstract spill storage service.',
methods: [
'abstract saveText(input: SaveTextSpill): Promise<SpillRef>',
],
},
{
key: 'subagents',
summary: 'Named provider registry and capability-checked start surface.',
@@ -566,13 +585,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'AssembledSection',
declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n}',
},
{
name: 'BashEnvContributor',
declaration: 'export interface BashEnvContributor {\n name: string;\n variables: Readonly<Record<DshEnvironmentKey, BashEnvVariable>>;\n resolve(execution: ToolExecution): Readonly<Partial<Record<DshEnvironmentKey, string>>>;\n}',
},
{
name: 'BashEnvVariable',
declaration: 'export interface BashEnvVariable {\n description: string;\n}',
},
{
name: 'BashEnvVariableInfo',
declaration: 'export interface BashEnvVariableInfo extends BashEnvVariable {\n contributor: string;\n key: DshEnvironmentKey;\n}',
},
{
name: 'BashExecRequest',
declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n sandboxMode?: SandboxMode | undefined;\n}',
declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxMode?: SandboxMode | undefined;\n}',
},
{
name: 'BashExecSpec',
declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n sandboxMode: SandboxMode | undefined;\n}',
declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxMode: SandboxMode | undefined;\n}',
},
{
name: 'BashProcess',
@@ -670,6 +701,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'DiffResultView',
declaration: 'export interface DiffResultView {\n card: \'diff\';\n title?: string;\n diffs: FileDiff[];\n}',
},
{
name: 'DshEnvironment',
declaration: 'export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>;',
},
{
name: 'DshEnvironmentKey',
declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;',
},
{
name: 'FileDiff',
declaration: 'export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n}',
@@ -798,6 +837,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SandboxPolicy',
declaration: 'export interface SandboxPolicy {\n mode: ConfinedSandboxMode;\n workspaceRoot: string;\n}',
},
{
name: 'SaveTextSpill',
declaration: 'export interface SaveTextSpill {\n owner: SpillOwner;\n source: SpillSource;\n suggestedName: string;\n content: string;\n}',
},
{
name: 'ScopeKey',
declaration: 'export type ScopeKey = object;',
@@ -826,6 +869,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionEventSurface',
declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';',
},
{
name: 'SessionEventTrace',
declaration: 'export interface SessionEventTrace {\n target: SessionEventRecord;\n replacedBy?: number;\n replacementChain: number[];\n replacedEventSeqs: number[];\n sourceEventSeqs: number[];\n derivedEventSeqs: number[];\n}',
},
{
name: 'SessionEventTraceRequest',
declaration: 'export interface SessionEventTraceRequest {\n sessionId: SessionId;\n seq: number;\n}',
},
{
name: 'SessionEventType',
declaration: 'export type SessionEventType = keyof SessionEventMap;',
@@ -846,6 +897,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionId',
declaration: 'export type SessionId = Branded<\'SessionId\'>;',
},
{
name: 'SessionLineageNode',
declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n descendants: SessionLineageNode[];\n}',
},
{
name: 'SessionLineageTrace',
declaration: 'export type SessionLineageTrace = {\n target: SessionRecord;\n ancestors: SessionRecord[];\n descendants: SessionLineageNode[];\n} & ({\n complete: true;\n root: SessionRecord;\n} | {\n complete: false;\n unresolvedParentId: SessionId;\n});',
},
{
name: 'SessionLocation',
declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}',
},
{
name: 'SessionRecord',
declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}',
@@ -882,6 +945,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SkillSummary',
declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly disableModelInvocation?: boolean;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}',
},
{
name: 'SpillLocator',
declaration: 'export type SpillLocator = Branded<\'SpillLocator\'>;',
},
{
name: 'SpillOwner',
declaration: 'export interface SpillOwner {\n sessionId: SessionId;\n}',
},
{
name: 'SpillRef',
declaration: 'export interface SpillRef {\n locator: SpillLocator;\n bytes: number;\n retrievalHint: string;\n}',
},
{
name: 'SpillSource',
declaration: 'export interface SpillSource {\n toolName: string;\n callId: CallId;\n label: string;\n}',
},
{
name: 'StreamChunk',
declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n};',
@@ -1,11 +1,8 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as ToolCordis from '../src/index.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { REVERSE_TOOL_CODE } from './helpers.ts'
@@ -20,11 +17,7 @@ import { REVERSE_TOOL_CODE } from './helpers.ts'
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(ToolCordis)
ctx.llm.registerAdapter(['mock'], adapter)
+2 -2
View File
@@ -32,7 +32,7 @@ The store pairs announced creation with disposal, publishes post-commit append n
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
- `session.deriveMessages()` incrementally projects each new surface node once and returns a fresh array over shared frozen messages. A surface rewrite rebuilds the projection; there is no raw-log fallback.
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants.
- `session.surface` lazily folds only new `surfaceOp` markers; `replaceGeneration` changes on every rewrite.
@@ -49,7 +49,7 @@ Durable values need one accepted representation, not a check followed by a secon
- `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them.
- `SurfaceIntent``{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
- `SurfaceNode``{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list.
- `foldSurface(events)` — replay the canonical surface transitions into detached current nodes and actual replacement ranges, rejecting surface-eligible events that lack their mandatory marker. `SurfaceManager` shares the same transitions while retaining its incremental cache.
- `foldSurface(events)` — replay the one canonical surface contract into detached current nodes and actual replacement ranges. The same pass rejects non-contiguous event seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface node; `SurfaceManager` shares the atomic transition while retaining its incremental cache.
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log.
### Request-header reconstruction (`request-header.ts`)
+24 -64
View File
@@ -15,7 +15,7 @@ import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { ContextEnvelope, CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager, isSurfaceEligibleType } from './surface.ts'
import { SurfaceManager } from './surface.ts'
import { foldRequestHeader } from './request-header.ts'
export * from './types.ts'
@@ -131,43 +131,6 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
return deepFreeze(record as unknown as SessionHeader)
}
/** Validate the runtime shape of surface metadata after its JSON snapshot. */
function assertSurfaceMetadataShape(
type: string,
surfaceOp: unknown,
sourceEventSeqs: unknown,
): void {
const eligible = isSurfaceEligibleType(type)
if (!eligible) {
if (surfaceOp !== undefined || sourceEventSeqs !== undefined) {
throw new Error(`session event "${type}" is not surface-eligible and cannot carry surface metadata`)
}
return
}
if (surfaceOp === undefined) {
throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`)
}
if (surfaceOp !== 'append') {
if (surfaceOp === null || typeof surfaceOp !== 'object' || Array.isArray(surfaceOp)) {
throw new Error(`session event "${type}" carries an invalid surfaceOp`)
}
const op = surfaceOp as Record<string, unknown>
const keys = Object.keys(op)
if (keys.length !== 3 || !Object.hasOwn(op, 'op') || !Object.hasOwn(op, 'start') || !Object.hasOwn(op, 'end')
|| op['op'] !== 'replace'
|| typeof op['start'] !== 'number' || !Number.isSafeInteger(op['start']) || op['start'] < 0
|| typeof op['end'] !== 'number' || !Number.isSafeInteger(op['end']) || op['end'] < 0) {
throw new Error(`session event "${type}" carries an invalid replace surfaceOp`)
}
}
if (sourceEventSeqs !== undefined) {
if (!Array.isArray(sourceEventSeqs)
|| sourceEventSeqs.some(seq => typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0)) {
throw new Error(`session event "${type}" sourceEventSeqs must contain non-negative safe integers`)
}
}
}
/** Validate the fixed event envelope after one-pass JSON materialization. */
function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
const event = value
@@ -250,13 +213,15 @@ export function renderContextContent(
*/
export class Session {
private log: SessionEvent[] = []
/** Incremental acceptance state, kept separate from the public lazy view. */
private readonly surfaceValidator = new SurfaceManager(this.log)
/**
* Derived surface a cached linked list of message-producing events.
* Lazily rebuilt from `surfaceOp` markers in the log; processes only new
* events (delta) on each access the log is append-only, so prior events
* never change.
* `append`. Undefined until first accessed (including after fork/seed).
* Undefined until first accessed (including after fork/seed).
*/
private _surface: SurfaceManager | undefined
@@ -285,7 +250,7 @@ export class Session {
// `seq = log.length` contract the whole system relies on). Without this,
// a bad seed would surface only later as a backend rejection or a silent
// divergence between the live log and disk.
this.log = Array.from(seed, (source, index) => {
for (const [index, source] of seed.entries()) {
// The seed is a persistence/replay boundary: validate and detach the
// complete event in one lossless-JSON pass.
const snapshot = snapshotJsonValue(source)
@@ -296,20 +261,16 @@ export class Session {
if (snapshot.seq !== index) {
throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`)
}
// Surface-eligible events MUST carry a surfaceOp marker — the surface is
// the sole source of derived history, so a marker-less message event
// would load fine yet vanish from deriveMessages(). `append` enforces
// this at compile time via its typed overload; a seed arrives as raw
// SessionEvent[] (replay/fork/load), bypassing that, so re-check at
// runtime here rather than silently resuming with empty history.
const structural = snapshot as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown }
// A seed is accepted incrementally through the same transition as a
// live append and a full-log fold. The candidate is planned before it
// enters `log`, so a failure cannot partially mutate the surface.
try {
assertSurfaceMetadataShape(snapshot.type, structural.surfaceOp, structural.sourceEventSeqs)
this.surfaceValidator.validateNext(snapshot)
} catch (error: unknown) {
throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
}
return deepFreeze(snapshot)
})
this.log.push(deepFreeze(snapshot))
}
}
this.header = snapshotSessionHeader(id, header)
}
@@ -356,7 +317,10 @@ export class Session {
* @throws if `data` or surface metadata is not losslessly JSON-serializable
* (BigInt, function, symbol, undefined, negative zero, non-finite number,
* circular reference, sparse array, or an exotic object such as
* Map/Set/Date/class instance). One recursive pass reads, validates, and
* Map/Set/Date/class instance), or when the candidate violates the
* canonical surface contract (marker shape and eligibility, unique
* earlier provenance, positional replacement validity, and complete
* shadowed-node coverage). One recursive pass reads, validates, and
* copies each nested value once, so a stateful getter cannot supply one value
* to validation and another to storage. The event log is the durable source
* of truth, so a bad event fails at the append site rather than later during
@@ -382,25 +346,21 @@ export class Session {
if (surfaceMetadataSnapshot === undefined) {
throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
}
assertSurfaceMetadataShape(
type,
(surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
(surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
)
const entry = attachments.get(this)
if (entry?.appending) {
throw new Error('session append cannot reenter while another append is being published')
}
const event = deepFreeze({
type,
seq: this.log.length,
time: Date.now(),
data: dataSnapshot,
...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }),
} as unknown as SessionEvent<T>)
this.surfaceValidator.validateNext(event as SessionEvent)
if (entry !== undefined) entry.appending = true
try {
const event = deepFreeze({
type,
seq: this.log.length,
time: Date.now(),
data: dataSnapshot,
...surfaceMetadataSnapshot,
} as unknown as SessionEvent<T>)
let callbacks: SessionCallback[] | undefined
const callbackArgs: unknown[] = [this, event]
if (entry !== undefined) {
+175 -40
View File
@@ -85,6 +85,18 @@ interface SurfaceFoldState {
replaceGeneration: number
}
/** A validated replacement transition that has not mutated fold state yet. */
interface SurfaceReplacePlan extends SurfaceFoldReplacement {
kind: 'replace'
startIdx: number
endIdx: number
}
/** One validated surface transition that has not mutated fold state yet. */
type SurfacePlan =
| { kind: 'append'; seq: number }
| SurfaceReplacePlan
/** Create an empty surface fold state. */
function createFoldState(replaceGeneration = 0): SurfaceFoldState {
return {
@@ -94,39 +106,89 @@ function createFoldState(replaceGeneration = 0): SurfaceFoldState {
}
}
/** Apply one event and return replacement metadata only when one occurred. */
function applySurfaceEvent(
state: SurfaceFoldState,
event: SessionEvent,
): SurfaceFoldReplacement | undefined {
if (!isSurfaceEligibleType(event.type)) return
if (!isSurfaceEvent(event)) {
throw new Error(`surface event "${event.type}" (seq ${event.seq}) carries no surfaceOp marker`)
}
/** Whether a runtime value is a non-negative safe event sequence. */
function isEventSeq(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
}
if (event.surfaceOp === 'append') {
const tail = state.nodes.length > 0 ? state.nodes[state.nodes.length - 1] : undefined
const node: SurfaceNode = { seq: event.seq, prev: tail?.seq ?? null, next: null }
if (tail) tail.next = event.seq
state.nodes.push(node)
state.nodeBySeq.set(event.seq, node)
/** Whether a runtime value is the exact positional-replacement shape. */
function isReplaceOp(value: object): value is Extract<SurfaceOp, { op: 'replace' }> {
const op = value as Record<string, unknown>
return Object.keys(op).length === 3
&& Object.hasOwn(op, 'op')
&& Object.hasOwn(op, 'start')
&& Object.hasOwn(op, 'end')
&& op['op'] === 'replace'
&& isEventSeq(op['start'])
&& isEventSeq(op['end'])
}
/** Validate event-local surface eligibility and return its operation. */
function surfaceOpOf(event: SessionEvent): SurfaceOp | undefined {
const raw = event as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown }
if (!isSurfaceEligibleType(event.type)) {
if (raw.surfaceOp !== undefined) {
throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`)
}
if (raw.sourceEventSeqs !== undefined) {
throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry sourceEventSeqs`)
}
return
}
const op = raw.surfaceOp
if (op === undefined) {
throw new Error(`session event "${event.type}" is surface-eligible and requires a surfaceOp marker`)
}
if (op === 'append') return op
if (op === null || typeof op !== 'object' || Array.isArray(op)) {
throw new Error(`session event "${event.type}" carries an invalid surfaceOp`)
}
if (!isReplaceOp(op)) {
throw new Error(`session event "${event.type}" carries an invalid replace surfaceOp`)
}
return op
}
return {
seq: event.seq,
start: event.surfaceOp.start,
end: event.surfaceOp.end,
shadowedSeqs: replaceSurface(state, event.seq, event.surfaceOp),
/** Validate provenance against prior log entries and the replacement range. */
function assertProvenance(
event: SessionEvent,
shadowedSeqs: readonly number[],
): void {
const raw = (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs
const sources = new Set<number>()
if (raw !== undefined) {
if (!Array.isArray(raw)) {
throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`)
}
if (raw.length === 0) {
throw new Error('sourceEventSeqs must not be empty when present')
}
let nonEarlierSource: number | undefined
for (const source of raw) {
if (!isEventSeq(source)) {
throw new Error(`session event "${event.type}" sourceEventSeqs must densely contain non-negative safe integers`)
}
sources.add(source)
if (nonEarlierSource === undefined && source >= event.seq) nonEarlierSource = source
}
if (sources.size !== raw.length) {
throw new Error('sourceEventSeqs must not contain duplicates')
}
if (nonEarlierSource !== undefined) {
throw new Error(`sourceEventSeqs must reference earlier events: ${nonEarlierSource} >= current seq ${event.seq}`)
}
}
const missing = shadowedSeqs.filter(seq => !sources.has(seq))
if (missing.length > 0) {
throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
}
}
/** Apply one positional replacement and return the nodes it removed. */
function replaceSurface(
/** Locate one replacement range without mutating the current fold state. */
function replacementRange(
state: SurfaceFoldState,
newSeq: number,
op: Extract<SurfaceOp, { op: 'replace' }>,
): number[] {
): Pick<SurfaceReplacePlan, 'startIdx' | 'endIdx' | 'shadowedSeqs'> {
const startNode = state.nodeBySeq.get(op.start)
if (!startNode) {
throw new Error(`surface replace: start seq ${op.start} not found in surface`)
@@ -140,6 +202,42 @@ function replaceSurface(
if (startIdx > endIdx) {
throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`)
}
return {
startIdx,
endIdx,
shadowedSeqs: state.nodes.slice(startIdx, endIdx + 1).map(node => node.seq),
}
}
/** Validate one event at its replay boundary and prepare its atomic fold transition. */
function planSurfaceEvent(
state: SurfaceFoldState,
event: SessionEvent,
expectedSeq: number,
): SurfacePlan | undefined {
if (event.seq !== expectedSeq) {
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
}
const surfaceOp = surfaceOpOf(event)
if (surfaceOp === undefined) return
if (surfaceOp === 'append') {
assertProvenance(event, [])
return { kind: 'append', seq: event.seq }
}
const range = replacementRange(state, surfaceOp)
assertProvenance(event, range.shadowedSeqs)
return {
kind: 'replace',
seq: event.seq,
start: surfaceOp.start,
end: surfaceOp.end,
...range,
}
}
/** Apply one already-validated positional replacement. */
function replaceSurface(state: SurfaceFoldState, plan: SurfaceReplacePlan): void {
const { startIdx, endIdx } = plan
const removed = state.nodes.splice(startIdx, endIdx - startIdx + 1)
for (const node of removed) state.nodeBySeq.delete(node.seq)
@@ -147,16 +245,40 @@ function replaceSurface(
const prevNode = startIdx > 0 ? state.nodes[startIdx - 1] : undefined
const nextNode = startIdx < state.nodes.length ? state.nodes[startIdx] : undefined
const newNode: SurfaceNode = {
seq: newSeq,
seq: plan.seq,
prev: prevNode?.seq ?? null,
next: nextNode?.seq ?? null,
}
if (prevNode) prevNode.next = newSeq
if (nextNode) nextNode.prev = newSeq
if (prevNode) prevNode.next = plan.seq
if (nextNode) nextNode.prev = plan.seq
state.nodes.splice(startIdx, 0, newNode)
state.nodeBySeq.set(newSeq, newNode)
state.nodeBySeq.set(plan.seq, newNode)
state.replaceGeneration += 1
return removed.map(node => node.seq)
}
/** Apply one event and return replacement metadata only when one occurred. */
function applySurfaceEvent(
state: SurfaceFoldState,
event: SessionEvent,
expectedSeq: number,
): SurfaceFoldReplacement | undefined {
const plan = planSurfaceEvent(state, event, expectedSeq)
if (plan?.kind === 'append') {
const tail = state.nodes.at(-1)
const node: SurfaceNode = { seq: plan.seq, prev: tail?.seq ?? null, next: null }
if (tail) tail.next = plan.seq
state.nodes.push(node)
state.nodeBySeq.set(plan.seq, node)
} else if (plan?.kind === 'replace') {
replaceSurface(state, plan)
}
if (plan?.kind !== 'replace') return
return {
seq: plan.seq,
start: plan.start,
end: plan.end,
shadowedSeqs: plan.shadowedSeqs,
}
}
/**
@@ -167,14 +289,16 @@ function replaceSurface(
* models cannot disagree with `deriveMessages()` about replacement ranges.
* @param events - session events in contiguous seq order.
* @returns the current surface and every positional replacement.
* @throws when a surface-eligible event lacks its mandatory `surfaceOp`, or a
* replacement names nodes that are absent or reversed on the current surface.
* @throws when any event violates the unified surface contract: metadata must
* be well shaped and type-eligible, event seqs must be contiguous, provenance
* must name unique earlier events, and a positional replacement must name and
* cite its complete range.
*/
export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
const state = createFoldState()
const replacements: SurfaceFoldReplacement[] = []
for (const event of events) {
const replacement = applySurfaceEvent(state, event)
for (const [index, event] of events.entries()) {
const replacement = applySurfaceEvent(state, event, index)
if (replacement !== undefined) replacements.push(replacement)
}
return {
@@ -184,11 +308,10 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
}
/**
* Maintains a cached linked list of surface nodes, rebuilt lazily from
* `surfaceOp` markers in the event log. Because the log is append-only, it
* processes only the delta since the last rebuild new events are folded
* into the existing surface in O(new events) rather than rescanning the
* whole log.
* Maintains a cached linked list of surface nodes and validates each candidate
* before it enters the event log. Because the log is append-only, it processes
* only committed deltas and plans the candidate without mutation rather than
* rescanning the whole log.
*/
export class SurfaceManager {
/** Incremental state shared with the complete surface fold. */
@@ -198,6 +321,18 @@ export class SurfaceManager {
constructor(private log: readonly SessionEvent[]) {}
/**
* Validate one candidate as the next log event without applying it. The
* committed log is folded first, then the candidate's complete surface and
* provenance transition is planned atomically; a failure leaves the current
* surface unchanged.
* @param event - candidate event that has not entered `log` yet.
*/
validateNext(event: SessionEvent): void {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
planSurfaceEvent(this._state, event, this.log.length)
}
/**
* The surface's rewrite generation, bumped by every folded `replace` op.
* A replace is the ONE operation that rewrites the
@@ -227,8 +362,8 @@ export class SurfaceManager {
// Index is bounded by i < this.log.length — never undefined.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const event = this.log[i]!
applySurfaceEvent(this._state, event)
applySurfaceEvent(this._state, event, i)
this._lastProcessedSeq = i
}
this._lastProcessedSeq = this.log.length - 1
}
}
+29 -7
View File
@@ -317,35 +317,52 @@ describe('Session', () => {
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
surfaceOp: 'append',
}, {
type: 'user/message',
seq: 1,
time: 2,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
surfaceOp,
sourceEventSeqs: [0],
}] as unknown as SessionEvent[]
const session = new Session(SessionId('seed-unstable-metadata'), seed)
const event = session.events[0]!
const event = session.events[1]!
if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message')
expect(reads).toBe(1)
expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
})
it('adds seed context when surface validation throws a non-Error value', () => {
it.each([
['an Error', new Error('validator failed'), 'validator failed'],
['a non-Error value', 'validator failed', 'invalid surface metadata'],
] as const)('adds seed context when surface validation throws %s', (_name, failure, expected) => {
const originalHasOwn = Object.hasOwn
const hasOwn = vi.spyOn(Object, 'hasOwn').mockImplementation((object: object, property: PropertyKey): boolean => {
if ((object as Record<string, unknown>)['op'] === 'replace') throw 'validator failed'
if ((object as Record<string, unknown>)['op'] === 'replace') throw failure
return originalHasOwn(object, property)
})
const seed = [{
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
surfaceOp: 'append',
}, {
type: 'user/message',
seq: 1,
time: 2,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
}] as unknown as SessionEvent[]
try {
expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed))
.toThrow('invalid seed event at index 0: invalid surface metadata')
.toThrow(`invalid seed event at index 1: ${expected}`)
} finally {
hasOwn.mockRestore()
}
@@ -431,6 +448,11 @@ describe('Session', () => {
it('reads a nested append-metadata getter once and stores its first JSON value', () => {
const session = new Session(SessionId('append-unstable-metadata'))
const source = session.append(
'user/message',
{ content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
{ surfaceOp: 'append' },
)
let reads = 0
const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', {
enumerable: true,
@@ -443,12 +465,12 @@ describe('Session', () => {
const event = session.append(
'user/message',
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
{ surfaceOp } as never,
{ surfaceOp, sourceEventSeqs: [0] } as never,
)
expect(reads).toBe(1)
expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
expect(session.events).toEqual([event])
expect(session.events).toEqual([source, event])
})
it('rejects invalid plain surface metadata shapes at append', () => {
@@ -484,7 +506,7 @@ describe('Session', () => {
'turn/start',
{ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
{ surfaceOp: 'append' },
)).toThrow(/not surface-eligible and cannot carry surface metadata/)
)).toThrow(/not surface-eligible and cannot carry surfaceOp/)
expect(() => new Session(SessionId('non-surface-metadata-seed'), [{
type: 'turn/start',
seq: 0,
+120 -26
View File
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'
import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
import { Session, SessionId, foldSurface, isSurfaceEligibleType, isSurfaceEvent } from '@deepseek-ai/dsh-session'
import {
Session,
SessionId,
foldSurface,
isSurfaceEligibleType,
isSurfaceEvent,
} from '@deepseek-ai/dsh-session'
import { CallId } from '@deepseek-ai/dsh-llm'
/** Build a minimal session with turn boundaries and a single user message. */
@@ -13,6 +19,64 @@ function surfaceSession(): Session {
return s
}
function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent {
return {
type: 'user/message',
seq,
time: seq,
data: { content: [], source: { kind: 'user' } },
surfaceOp: 'append',
...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
} as unknown as SessionEvent
}
describe('foldSurface provenance', () => {
it('accepts absent or valid provenance and complete replacement coverage', () => {
const events = [
provenanceEvent(0, undefined),
provenanceEvent(1, undefined),
{
...provenanceEvent(2, [0, 1]),
surfaceOp: { op: 'replace', start: 0, end: 1 },
},
] as SessionEvent[]
expect(() => foldSurface(events)).not.toThrow()
})
it('rejects provenance on a non-surface event', () => {
const event = {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
sourceEventSeqs: [0],
} as unknown as SessionEvent
expect(() => foldSurface([event])).toThrow(/cannot carry sourceEventSeqs/)
})
it.each([
['a non-array', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: 'invalid' }], /must be an array/],
['an empty array', [provenanceEvent(0, [])], /must not be empty/],
['duplicates', [provenanceEvent(0, undefined), provenanceEvent(1, [0, 0])], /must not contain duplicates/],
['a sparse array', [provenanceEvent(0, Array<number>(1))], /densely contain/],
['a non-number', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: ['0'] }], /non-negative safe integers/],
['a fractional number', [provenanceEvent(0, [0.5])], /non-negative safe integers/],
['a negative number', [provenanceEvent(0, [-1])], /non-negative safe integers/],
['a self reference', [provenanceEvent(0, [0])], /must reference earlier events/],
['a non-contiguous event seq', [provenanceEvent(0, undefined), provenanceEvent(2, [1])], /seq 2 is not contiguous; expected 1/],
['incomplete replacement coverage', [
provenanceEvent(0, undefined),
provenanceEvent(1, undefined),
{ ...provenanceEvent(2, [0]), surfaceOp: { op: 'replace', start: 0, end: 1 } },
], /missing 1/],
] as const)(
'rejects %s',
(_name, events, expected) => {
expect(() => foldSurface(events as unknown as SessionEvent[])).toThrow(expected)
},
)
})
describe('SurfaceManager', () => {
it('shares exact nodes and nested replacement ranges with foldSurface', () => {
const s = new Session(SessionId('shared-fold'))
@@ -36,7 +100,7 @@ describe('SurfaceManager', () => {
it('does not retain fold-only replacement history in incremental state', () => {
const s = new Session(SessionId('incremental-state'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 } })
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
expect(s.surface.nodes).toEqual([{ seq: 1, prev: null, next: null }])
const manager = s.surface as unknown as { _state: object }
@@ -47,12 +111,29 @@ describe('SurfaceManager', () => {
})
it('foldSurface reports the same invalid replacement failures as the incremental manager', () => {
const s = new Session(SessionId('shared-fold-invalid'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 42, end: 0 }, sourceEventSeqs: [0] })
const events = [
provenanceEvent(0, undefined),
{ ...provenanceEvent(1, [0]), surfaceOp: { op: 'replace', start: 42, end: 0 } },
] as SessionEvent[]
expect(() => foldSurface(s.events)).toThrow(/start seq 42 not found/)
expect(() => s.surface.nodes).toThrow(/start seq 42 not found/)
expect(() => foldSurface(events)).toThrow(/start seq 42 not found/)
expect(() => new Session(SessionId('shared-fold-invalid'), events))
.toThrow(/start seq 42 not found/)
})
it('leaves incremental state unchanged when candidate validation fails', () => {
const s = new Session(SessionId('atomic-validation'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(() => s.append(
'assistant/message',
{ turn: 1, step: 1, content: [{ type: 'text', text: 'invalid' }] },
{ surfaceOp: { op: 'replace', start: 0, end: 0 } },
)).toThrow(/missing 0/)
expect(s.events).toHaveLength(1)
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(s.surface.nodes.map(node => node.seq)).toEqual([0, 1])
})
it('foldSurface rejects a surface-eligible event without its mandatory marker', () => {
@@ -64,7 +145,20 @@ describe('SurfaceManager', () => {
}
expect(() => foldSurface([malformed]))
.toThrow(/surface event "user\/message" \(seq 0\) carries no surfaceOp marker/)
.toThrow(/surface-eligible and requires a surfaceOp marker/)
})
it('foldSurface rejects surfaceOp on a non-surface event', () => {
const malformed = {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
surfaceOp: 'append',
} as unknown as SessionEvent
expect(() => foldSurface([malformed]))
.toThrow(/not surface-eligible and cannot carry surfaceOp/)
})
it('rebuilds a linked list from surfaceOp: append markers', () => {
@@ -161,21 +255,19 @@ describe('SurfaceManager', () => {
it('throws when replace start is not found', () => {
const s = new Session(SessionId('bad-start'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('assistant/message',
expect(() => s.append('assistant/message',
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
{ surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [5, 0] },
)
expect(() => s.surface.nodes).toThrow(/surface replace: start seq 5 not found/)
{ surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [0] },
)).toThrow(/surface replace: start seq 5 not found/)
})
it('throws when replace end is not found', () => {
const s = new Session(SessionId('bad-end'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('assistant/message',
expect(() => s.append('assistant/message',
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
{ surfaceOp: { op: 'replace', start: 0, end: 99 }, sourceEventSeqs: [0] },
)
expect(() => s.surface.nodes).toThrow(/surface replace: end seq 99 not found/)
)).toThrow(/surface replace: end seq 99 not found/)
})
it('throws when start is after end', () => {
@@ -183,22 +275,22 @@ describe('SurfaceManager', () => {
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
// start=1, end=0 would be reversed order.
s.append('assistant/message',
expect(() => s.append('assistant/message',
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
{ surfaceOp: { op: 'replace', start: 1, end: 0 }, sourceEventSeqs: [1, 0] },
)
expect(() => s.surface.nodes).toThrow(/start seq 1.*after end seq 0/)
)).toThrow(/start seq 1.*after end seq 0/)
})
it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => {
const s = new Session(SessionId('immutable'))
const sources = [10, 20]
s.append('user/message', { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const sources = [0]
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] }, { surfaceOp: 'append', sourceEventSeqs: sources })
// Mutate caller's array after append.
sources.push(30)
sources.push(1)
sources[0] = 99
const logged = s.events[0]! as SurfaceEvent
expect(logged.sourceEventSeqs).toEqual([10, 20])
const logged = s.events[1]! as SurfaceEvent
expect(logged.sourceEventSeqs).toEqual([0])
})
it('replace starting at non-head position links to previous node correctly', () => {
@@ -280,15 +372,17 @@ describe('deriveMessages with surface', () => {
describe('Session.append surface opts', () => {
it('records sourceEventSeqs and surfaceOp on the event', () => {
const s = new Session(SessionId('opts'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
const event = s.append('assistant/message',
{ turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] },
{ surfaceOp: 'append', sourceEventSeqs: [3, 5, 7] },
{ surfaceOp: 'append', sourceEventSeqs: [0, 1] },
)
expect(event.sourceEventSeqs).toEqual([3, 5, 7])
expect(event.sourceEventSeqs).toEqual([0, 1])
expect(event.surfaceOp).toBe('append')
// The logged event matches the returned event.
expect((s.events[0]! as SurfaceEvent).sourceEventSeqs).toEqual([3, 5, 7])
expect((s.events[0]! as SurfaceEvent).surfaceOp).toBe('append')
expect((s.events[2]! as SurfaceEvent).sourceEventSeqs).toEqual([0, 1])
expect((s.events[2]! as SurfaceEvent).surfaceOp).toBe('append')
})
it('deriveMessages skips a surface node that derives to null (empty assistant/message)', () => {
@@ -241,10 +241,11 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace
// summary user/message — appended now, so it carries a high log seq.
const u1 = seqOf(s, 'user/message')
const result = s.events.find(e => e.type === 'tool/result')!.seq
const shadowedSeqs = s.surface.nodes.map(node => node.seq)
s.append('user/message', {
content: [{ type: 'text', text: 'CHECKPOINT' }],
source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: u1, end: result } })
}, { surfaceOp: { op: 'replace', start: u1, end: result }, sourceEventSeqs: shadowedSeqs })
// The step's own assistant/message lands AFTER the checkpoint in the log,
// still inside the open step.
s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'a2' }] }, SURFACE)
@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {
+1
View File
@@ -28,6 +28,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
| `model` | (required) | the per-session agent template the bridge creates agents from |
| `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
+3
View File
@@ -38,6 +38,8 @@ export interface Config {
toolOrder?: string[]
/** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */
tools?: ToolsConfig
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
dshHome?: string
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
@@ -61,6 +63,7 @@ export const Config: z<Config> = z.object({
// schemastery's native [] default would read as an invalid configured list.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
dshHome: z.string(),
// TODO(single-default-literal): share this schema default and the defensive
// apply() fallback through one named constant while retaining both boundaries.
persistenceRoot: z.string().default('./.sessions'),
@@ -118,8 +118,9 @@ describe('dsh-acp-demo composition', () => {
})
})
it('forwards skill config into agent-spine-demo', async () => {
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6), workspaceContext: false })
it('forwards skill config and dshHome into agent-spine-demo', async () => {
const skills = await isolatedSkillsConfig(6)
const ctx = await mount({ model: 'mock', persona: 'hi', dshHome: skills.local!.dshHome!, skills, workspaceContext: false })
ctx.skills.register({ name: 'acp-skill', description: 'ACP skill', source: 'runtime', content: 'body' })
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `acp-skill`: ACP...')
await ctx.fiber.dispose()
+2 -2
View File
@@ -42,11 +42,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
```ts
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// { agents?, persona?, toolOrder?, tools?, skills?, workspaceContext, toolBash?, toolTasks? }
// { agents?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? }
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
## Why a code bundle, not a shared YAML include
@@ -26,6 +26,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-home": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -45,6 +46,7 @@
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-home": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
+23 -10
View File
@@ -25,6 +25,7 @@ import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
import * as toolTasks from '@deepseek-ai/dsh-tool-tasks'
import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop'
import { resolveDshHome } from '@deepseek-ai/dsh-home'
export const name = 'agent-spine-demo'
@@ -44,13 +45,14 @@ export interface SkillConfig {
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
* plugin (the deployment's persona section and the explicit model-facing tool
* order), the `tools` object to the tool registry (its presentation `mode`),
* `skills` to the skill registry/local provider/tool consumer,
* `workspaceContext` to the workspace-context loader, and
* `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns.
* Owner schemas supply defaults for optional input; workspace context instead
* requires an explicit byte budget or `false` because it changes model-visible
* input. Producer opt-in stays producer-local: `toolBash` configures bash only;
* independently composed producers keep their own config.
* `dshHome` to bash environment and local skill discovery, `skills` to the
* skill registry/local provider/tool consumer, `workspaceContext` to the
* workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool
* plugins this bundle owns. Owner schemas supply defaults for optional input;
* workspace context instead requires an explicit byte budget or `false` because
* it changes model-visible input. Producer opt-in stays producer-local:
* `toolBash` configures bash only; independently composed producers keep their
* own config.
*/
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
@@ -61,6 +63,8 @@ export interface Config {
toolOrder?: SystemPromptConfig['toolOrder']
/** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
tools?: ToolsConfig
/** DeepSeek Harness home directory shared by shell context and local skill discovery. */
dshHome?: string
/** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
workspaceContext: workspaceContext.Config | false
/** Skill registry, local provider, and model-facing consumer config. */
@@ -90,11 +94,12 @@ export const Config = z.intersect([
SystemPrompt.Config,
z.object({
tools: ToolRegistry.Config,
dshHome: z.string(),
skills: SkillConfigSchema,
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
toolBash: ToolBashConfigSchema,
toolTasks: ToolTasksConfigSchema,
}) as unknown as z<Pick<Config, 'tools' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks'>>,
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks'>>,
]) as unknown as z<Config>
/**
@@ -107,6 +112,7 @@ export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'a
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
...config.tools !== undefined ? { tools: config.tools } : {},
...config.dshHome !== undefined ? { dshHome: config.dshHome } : {},
workspaceContext: config.workspaceContext,
...config.skills !== undefined ? { skills: config.skills } : {},
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
@@ -125,6 +131,13 @@ export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'a
* seams, then the loop that drives them.
*/
export function apply(ctx: Context, config: Config): void {
const nestedDshHome = config.skills?.local?.dshHome
if (config.dshHome !== undefined && nestedDshHome !== undefined
&& resolveDshHome(config.dshHome) !== resolveDshHome(nestedDshHome)) {
throw new Error('agent-core: dshHome and skills.local.dshHome must resolve to the same directory')
}
const dshHome = resolveDshHome(config.dshHome ?? nestedDshHome)
ctx.plugin(Timer)
ctx.plugin(LlmService)
ctx.plugin(SessionStore)
@@ -135,11 +148,11 @@ export function apply(ctx: Context, config: Config): void {
})
ctx.plugin(ToolRegistry, config.tools ?? {})
ctx.plugin(SkillService, config.skills?.registry ?? {})
ctx.plugin(SkillLocal, config.skills?.local ?? {})
ctx.plugin(SkillLocal, Object.assign({}, config.skills?.local, { dshHome }))
ctx.plugin(AgentRegistry)
ctx.plugin(TaskService)
ctx.plugin(invariants)
ctx.plugin(toolBash, config.toolBash ?? {})
ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome }))
if (config.workspaceContext !== false) {
ctx.plugin(workspaceContext, config.workspaceContext)
}

Some files were not shown because too many files have changed in this diff Show More