mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge pull request #186 from deepseek-harness/codex/truncated-design
feat(spill): add bounded tool-result retention and spill policy
This commit is contained in:
@@ -84,6 +84,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 +120,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
|
||||
@@ -161,6 +167,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
|
||||
@@ -209,6 +216,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.
|
||||
|
||||
@@ -676,6 +676,40 @@ export interface Config {
|
||||
|
||||
Source: [`packages/skill/skill-local/src/index.ts:39`](../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`
|
||||
|
||||
Requires: `agents` · `userInteraction`
|
||||
@@ -938,6 +972,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 +1352,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)
|
||||
@@ -1311,6 +1368,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
|
||||
- `@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))
|
||||
|
||||
@@ -217,6 +217,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.
|
||||
|
||||
@@ -6,7 +6,7 @@ Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.t
|
||||
|
||||
## 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 +15,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
|
||||
/**
|
||||
@@ -57,6 +64,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
|
||||
/**
|
||||
@@ -88,6 +100,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
56
docs/core-data-structures/spill.md
Normal file
56
docs/core-data-structures/spill.md
Normal 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`.
|
||||
@@ -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`) | - |
|
||||
|
||||
@@ -10,6 +10,7 @@ flowchart TD
|
||||
subgraph group_util["packages/util"]
|
||||
pkg_brand["brand"]
|
||||
pkg_paths["paths"]
|
||||
pkg_retention["retention"]
|
||||
pkg_timeout["timeout"]
|
||||
end
|
||||
subgraph group_llm["packages/llm"]
|
||||
@@ -36,6 +37,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 +65,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
|
||||
@@ -172,6 +179,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 +193,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
|
||||
@@ -251,6 +262,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 +281,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
|
||||
@@ -388,6 +411,7 @@ flowchart TD
|
||||
| --- | --- | --- |
|
||||
| [`brand`](../packages/util/brand) | `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` | — |
|
||||
@@ -418,11 +442,13 @@ flowchart TD
|
||||
| [`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) |
|
||||
@@ -440,9 +466,11 @@ flowchart TD
|
||||
| [`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-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) |
|
||||
|
||||
@@ -79,6 +79,7 @@ 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 |
|
||||
| [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 |
|
||||
| [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 |
|
||||
@@ -146,8 +147,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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()`. |
|
||||
@@ -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`
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
7
examples/acp-agent/tests/snapshots/bash-spill/input.json
Normal file
7
examples/acp-agent/tests/snapshots/bash-spill/input.json
Normal file
@@ -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." }
|
||||
]
|
||||
}
|
||||
23
examples/acp-agent/tests/snapshots/bash-spill/session.jsonl
Normal file
23
examples/acp-agent/tests/snapshots/bash-spill/session.jsonl
Normal file
@@ -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"}}
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
10
knip.json
10
knip.json
@@ -40,6 +40,11 @@
|
||||
"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 +137,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"],
|
||||
|
||||
@@ -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,8 +21,9 @@ 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 |
|
||||
@@ -32,13 +33,13 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`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 |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, 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)).
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ 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.
|
||||
- **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*`), 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).
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -92,10 +92,13 @@ 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.
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
@@ -111,7 +114,8 @@ 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,
|
||||
@@ -128,7 +132,8 @@ 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,
|
||||
|
||||
@@ -52,8 +52,10 @@ export function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
|
||||
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
|
||||
/**
|
||||
@@ -283,8 +285,8 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
|
||||
? 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 () => {
|
||||
|
||||
@@ -26,7 +26,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,
|
||||
}
|
||||
@@ -224,10 +225,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 +257,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 +268,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)
|
||||
@@ -364,7 +379,7 @@ describe('environment and spill-file hardening', () => {
|
||||
|
||||
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 +390,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-/)
|
||||
|
||||
@@ -27,7 +27,7 @@ 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?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, 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).
|
||||
|
||||
|
||||
@@ -34,6 +34,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
|
||||
/**
|
||||
@@ -67,6 +74,11 @@ 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. */
|
||||
@@ -96,9 +108,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -34,7 +34,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`, 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.
|
||||
|
||||
## Permissions and escalation
|
||||
|
||||
|
||||
@@ -101,6 +101,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 +141,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')) }
|
||||
@@ -927,11 +934,12 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
/**
|
||||
* 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,6 +952,7 @@ 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 } : {},
|
||||
@@ -980,7 +989,7 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
return { ctx, bash: ctx.bash as RecordingBashExecutor }
|
||||
}
|
||||
|
||||
it('does not forward env/stdin even when the model includes them as extra arguments', async () => {
|
||||
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 +1002,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 +1010,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 +1024,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 +1036,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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -189,6 +189,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.',
|
||||
@@ -568,11 +575,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
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 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 sandboxMode: SandboxMode | undefined;\n}',
|
||||
},
|
||||
{
|
||||
name: 'BashProcess',
|
||||
@@ -798,6 +805,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;',
|
||||
@@ -882,6 +893,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};',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -36,20 +36,37 @@ async function pkgName(absDir: string): Promise<string> {
|
||||
return json.name
|
||||
}
|
||||
|
||||
async function installWorkspacePackageCopy(absDir: string, target: string): Promise<void> {
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await cp(absDir, target, {
|
||||
recursive: true,
|
||||
filter: source => !source.split('/').includes('node_modules'),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a temporary external consumer with built workspace/vendor links and a mock-backed config.
|
||||
* The optional missing-but-disabled plugin verifies load guards accept intentionally fiber-less
|
||||
* entries rather than treating them as import failures.
|
||||
*/
|
||||
async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promise<string> {
|
||||
async function makeConsumer(
|
||||
welcome: string,
|
||||
disabledBrokenEntry = false,
|
||||
extraDshPackages: string[] = [],
|
||||
extraEntries: string[] = [],
|
||||
): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-'))
|
||||
const nm = join(dir, 'node_modules')
|
||||
for (const rel of dshPackages) {
|
||||
for (const rel of [...dshPackages, ...extraDshPackages]) {
|
||||
const abs = join(repoRoot, 'packages', rel)
|
||||
const name = await pkgName(abs)
|
||||
const target = join(nm, name)
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await symlink(abs, target)
|
||||
if (extraDshPackages.includes(rel)) {
|
||||
await installWorkspacePackageCopy(abs, target)
|
||||
} else {
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await symlink(abs, target)
|
||||
}
|
||||
}
|
||||
for (const v of vendorPackages) {
|
||||
const abs = join(repoRoot, 'vendor', v)
|
||||
@@ -77,6 +94,7 @@ async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promi
|
||||
' persona: \'demo\'',
|
||||
' workspaceContext: false',
|
||||
` welcome: '${welcome}'`,
|
||||
...extraEntries,
|
||||
...disabledBrokenEntry
|
||||
? ['- id: off', ' name: \'./src/does-not-exist.ts\'', ' disabled: true']
|
||||
: [],
|
||||
@@ -148,6 +166,27 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-demo BUILT bin (node lib/bin.j
|
||||
expect(code).toBe(0)
|
||||
}, 30_000)
|
||||
|
||||
it('boots when optional spill plugins are loaded from a built consumer install', async () => {
|
||||
consumer = await makeConsumer(
|
||||
'SPILL-OK ready.',
|
||||
false,
|
||||
['spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention'],
|
||||
[
|
||||
'- id: spill-local',
|
||||
' name: \'@deepseek-ai/dsh-spill-local\'',
|
||||
'- id: spill-policy',
|
||||
' name: \'@deepseek-ai/dsh-spill-policy\'',
|
||||
' config:',
|
||||
' maxInlineBytes: 50000',
|
||||
],
|
||||
)
|
||||
const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', '')
|
||||
expect(stderr).not.toContain('failed to load')
|
||||
expect(stderr).not.toContain('Cannot find package')
|
||||
expect(stdout).toContain('SPILL-OK ready.')
|
||||
expect(code).toBe(0)
|
||||
}, 30_000)
|
||||
|
||||
it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
|
||||
// boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config
|
||||
// directory cannot break its import; the include plugin's own read must fail loud instead.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# fs/ - filesystem capability family
|
||||
|
||||
The filesystem stack: a provider seam (text IO + atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), and the model-facing file tools + executor. All **product** packages.
|
||||
The filesystem stack: a provider seam (text IO + atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), the model-facing file tools + executor, and the bash-backed discovery tools. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
@@ -8,9 +8,10 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
|
||||
| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
|
||||
| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
|
||||
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
|
||||
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools, backed by fixed ripgrep commands through the bash seam (`ctx.bash`), NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it.
|
||||
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
|
||||
|
||||
## No timeouts on file IO
|
||||
|
||||
`read`/`write`/`edit` take **no** `timeoutMs`, and the provider seam arms no deadline — unlike bash and web, which consume [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md). A local syscall is best-effort-abortable at most: a timeout could not force an in-progress `fsync`/`rename` to stop, so a deadline here would be a knob that cannot deliver on its promise. Adding one would also be an implicit default in the exact place explicit-over-implicit forbids. Both reference agents (Claude Code, Codex) leave file IO untimed for the same reason; cancellation still propagates through the tool-execution signal for best-effort abort at syscall boundaries.
|
||||
`read`/`write`/`edit` take **no** `timeoutMs`, and the provider seam arms no deadline — unlike bash and web (which consume [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md)) and the bash-backed `glob`/`grep` (whose declared `timeoutMs` is enforced by `@deepseek-ai/dsh-timeout-policy`): those are process-backed, where a deadline can really kill the work. A local syscall is best-effort-abortable at most: a timeout could not force an in-progress `fsync`/`rename` to stop, so a deadline here would be a knob that cannot deliver on its promise. Adding one would also be an implicit default in the exact place explicit-over-implicit forbids. Both reference agents (Claude Code, Codex) leave file IO untimed for the same reason; cancellation still propagates through the tool-execution signal for best-effort abort at syscall boundaries.
|
||||
|
||||
90
packages/fs/tool-fs-search/README.md
Normal file
90
packages/fs/tool-fs-search/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# @deepseek-ai/dsh-tool-fs-search
|
||||
|
||||
The **model-facing filesystem discovery tools** — `glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
|
||||
|
||||
```ts ignore-check
|
||||
// Default deployment: a bash executor, then the discovery tools.
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
|
||||
await ctx.plugin(ToolFsSearch) // this package — registers glob/grep
|
||||
// Optional: a spill backend makes capped results fully recoverable.
|
||||
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
|
||||
```
|
||||
|
||||
Why bash-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution (local, sandboxed, remote); this package owns schemas, argument validation, shell quoting, parsing, retention, formatted-result spill, and timeout declaration. The tools never call `ctx.bash.start()` and never expose a bash task id — the call returns only after `rg` exits, times out, is aborted, or fails.
|
||||
|
||||
## Deployment requirement: co-located bash + filesystem
|
||||
|
||||
Returned paths are displayed relative to the resolved bash workdir (the calling agent's session cwd when present, else the executor's configured default) and are follow-up-readable with `read` only when the bash workdir and the filesystem root are the same workspace. v1 documents that requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend.
|
||||
|
||||
## Config
|
||||
|
||||
All keys are optional; the defaults are the shipped search caps.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `globMaxResults` | `100` | Max paths one `glob` call retains inline (matches Claude Code's `GlobTool` limit); later paths go to the formatted spill artifact. |
|
||||
| `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. |
|
||||
| `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. |
|
||||
| `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. |
|
||||
| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the bash backend's own timeout stays a second safety cap. |
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Arguments | Behavior |
|
||||
|---|---|---|
|
||||
| `glob` | `pattern`, `path?` | `rg --files --glob <pattern> --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one path per line, modification-time ordered. |
|
||||
| `grep` | `pattern`, `path?`, `include?` | Line-oriented `rg --json` parse (no colon-splitting ambiguity). `pattern` is a ripgrep regex; `path` is an optional **file or directory** target; `include` is ONE positive glob filter — a comma-separated list or a negated (`!…`) value is rejected up front (brace alternation like `*.{ts,tsx}` is fine). Returns matches grouped by file as `Line N: <preview>`. |
|
||||
|
||||
Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results follows the returned spill locator's retrieval hint.
|
||||
|
||||
## Two budgets, two artifacts
|
||||
|
||||
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. The model-facing recovery artifact is different: when a search yields more logical results than the inline cap, the tool saves the COMPLETE formatted result through `ctx.spillStore.saveText()` (suggested names `glob-results.txt` / `grep-results.txt`, owner = the calling session, source = the tool execution identity) and appends a footer naming the returned locator and retrieval hint. This is the first tool-owned spill call in the codebase — deliberate, because retention here is item-level: the generic `@deepseek-ai/dsh-spill-policy` only sees the final text on `tools/post-execute`, by which point a capped search has already omitted later paths/matches. A missing spill backend, a call with no session owner, or a `saveText()` failure keeps the inline page and reports that the complete result could not be saved — never an `isError`.
|
||||
|
||||
## Errors
|
||||
|
||||
Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (missing `rg`, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt
|
||||
|
||||
**What the model sees**: Every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section.
|
||||
|
||||
**Token effect**: Fixed guidance cost per request while the plugin is active.
|
||||
|
||||
#### Glob guidance
|
||||
|
||||
```markdown
|
||||
Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.
|
||||
```
|
||||
|
||||
#### Grep guidance
|
||||
|
||||
```markdown
|
||||
Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.
|
||||
```
|
||||
|
||||
### Tool schemas
|
||||
|
||||
**What the model sees**: The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) while this surface is visible.
|
||||
|
||||
**Token effect**: Fixed schema cost on every request where the tools are visible.
|
||||
|
||||
### Results and spill notices
|
||||
|
||||
**What the model sees**: `glob` returns one path per line; `grep` groups `Line <line>: <preview>` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved.
|
||||
|
||||
**Token effect**: Inline paths and matches are bounded by `globMaxResults`, `grepMaxMatches`, and `grepMaxLineBytes`; the call and retained result remain in history until compaction.
|
||||
|
||||
### Tool errors
|
||||
|
||||
**What the model sees**: Failures are normalized as `Error: <message>` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata for callers.
|
||||
|
||||
**Token effect**: Only a failing call adds these retained tokens.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation.
|
||||
- **Ripgrep is a deployment dependency** — a missing or incompatible `rg` executable fails calls with `SEARCH_FAILED`; remote or virtual filesystems need a co-located executor or another search consumer.
|
||||
- **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend.
|
||||
49
packages/fs/tool-fs-search/package.json
Normal file
49
packages/fs/tool-fs-search/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-fs-search",
|
||||
"description": "Model-facing filesystem discovery tools (glob, grep) backed by the DeepSeek Harness bash seam (ctx.bash)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-retention": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-spill": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-retention": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
179
packages/fs/tool-fs-search/src/glob.ts
Normal file
179
packages/fs/tool-fs-search/src/glob.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The model-facing `glob` tool: discover files whose paths match a glob
|
||||
* pattern, sorted by modification time. Execution goes through the bash seam
|
||||
* (`ctx.bash`) with a fixed `rg --files` command — this module owns the
|
||||
* model-facing schema, argument validation, shell-safe command construction,
|
||||
* result parsing, retention, and formatting; process concerns (defaulting,
|
||||
* scrubbing, kill, backend substitution) stay behind `ctx.bash`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/glob
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { ItemRetainer } from '@deepseek-ai/dsh-retention'
|
||||
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
|
||||
import type { SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type {} from '@deepseek-ai/dsh-bash'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
import { singleQuote } from './shell-quote.ts'
|
||||
|
||||
/**
|
||||
* Default cap on paths retained inline by one `glob` call (the `globMaxResults`
|
||||
* config), matching Claude Code's default `GlobTool` result limit.
|
||||
*/
|
||||
export const GLOB_MAX_RESULTS = 100
|
||||
|
||||
/**
|
||||
* Directory names ripgrep must never descend into for a discovery listing: VCS
|
||||
* metadata stores. `--no-ignore --hidden` would otherwise surface them in every
|
||||
* broad search. Each name is excluded with TWO negated `--glob`s (see
|
||||
* {@link buildGlobCommand}): an any-depth directory glob that matches — and
|
||||
* prunes — the directory during traversal, and a contents glob that still
|
||||
* excludes the internals when the search root itself is at or inside the
|
||||
* directory (an explicit `path` of `.git` or `sub/.git`), where the prune glob
|
||||
* alone never matches.
|
||||
*/
|
||||
export const GLOB_VCS_EXCLUDES: readonly string[] = ['.git', '.svn', '.hg', '.bzr', '.jj', '.sl']
|
||||
|
||||
/** Resolved glob-tool caps — plugin config after defaulting (see `Config` in index.ts). */
|
||||
export interface GlobToolCaps {
|
||||
/** Max paths retained inline; later paths go to the formatted spill file. */
|
||||
maxResults: number
|
||||
/** Cap on the complete raw `rg` stdout the tool will parse. */
|
||||
rawOutputMaxBytes: number
|
||||
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
/** Validated `glob` arguments. */
|
||||
export interface GlobInput {
|
||||
pattern: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate value constraints the schema DSL can't express: a non-blank
|
||||
* `pattern`, and a non-blank `path` when given. Throws a plain `Error` (an
|
||||
* ordinary tool argument error) otherwise.
|
||||
*
|
||||
* @param args - the schema-validated `glob` arguments.
|
||||
* @returns the accepted input, unchanged.
|
||||
*/
|
||||
export function parseGlobArgs(args: { pattern: string; path?: string }): GlobInput {
|
||||
if (args.pattern.trim().length === 0) throw new Error('pattern must be a non-empty string')
|
||||
if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given')
|
||||
return { pattern: args.pattern, ...args.path !== undefined ? { path: args.path } : {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fixed `rg --files` command for one `glob` call. Every
|
||||
* model-controlled value ({@link GlobInput.pattern}, {@link GlobInput.path})
|
||||
* passes through {@link singleQuote}; the search root rides behind `--` so a
|
||||
* leading-dash path can never be parsed as a flag. `--sort=modified` orders by
|
||||
* modification time, `--no-ignore --hidden` searches ignored and hidden files,
|
||||
* and {@link GLOB_VCS_EXCLUDES} keeps VCS metadata out.
|
||||
*
|
||||
* @param input - the validated arguments.
|
||||
* @returns the complete, shell-safe command string.
|
||||
*/
|
||||
export function buildGlobCommand(input: GlobInput): string {
|
||||
const parts = [
|
||||
'rg --files',
|
||||
`--glob=${singleQuote(input.pattern)}`,
|
||||
'--sort=modified --no-ignore --hidden',
|
||||
// Two negated globs per VCS name: the bare form prunes the directory
|
||||
// during traversal; the /** form still excludes the contents when the
|
||||
// search root is AT or INSIDE the directory (where the bare form,
|
||||
// matched against root-prefixed paths, never fires).
|
||||
...GLOB_VCS_EXCLUDES.flatMap(name => [
|
||||
`--glob=${singleQuote(`!**/${name}`)}`,
|
||||
`--glob=${singleQuote(`!**/${name}/**`)}`,
|
||||
]),
|
||||
]
|
||||
if (input.path !== undefined) parts.push('--', singleQuote(input.path))
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the model-facing `glob` result: the retained paths, then — when the
|
||||
* result was capped — a footer carrying either the formatted-spill recovery
|
||||
* locator or the could-not-save explanation. The omitted count is a budget fact:
|
||||
* the search itself completed.
|
||||
*
|
||||
* @param retained - the retention outcome over every discovered path.
|
||||
* @param spillRef - the saved complete-result reference, or `undefined` when unsaved.
|
||||
* @returns the model-facing text.
|
||||
*/
|
||||
export function formatGlobOutput(retained: RetainedItems<string>, spillRef: SpillRef | undefined): string {
|
||||
const body = retained.items.join('\n')
|
||||
if (!retained.truncated) return body
|
||||
const recovery = spillRef !== undefined
|
||||
? `Full sorted result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`
|
||||
: 'The complete result could not be saved; narrow pattern or path to see more.'
|
||||
return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending-call presentation: a search card titled by the pattern (and root).
|
||||
*
|
||||
* @param args - the raw tool arguments; `pattern` and `path` feed the title.
|
||||
* @returns the generic card view (`kind: 'search'`) shown while the call runs.
|
||||
*/
|
||||
export function presentGlobCall(args: { pattern: string; path?: string }): GenericCallView {
|
||||
const where = args.path !== undefined ? ` in ${args.path}` : ''
|
||||
return { card: 'generic', title: `Glob ${args.pattern}${where}`, kind: 'search', rawInput: args.pattern }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `glob` tool and its system-prompt guidance.
|
||||
*
|
||||
* @param ctx - the plugin context; registrations are effects scoped to it, and
|
||||
* execution uses its `bash` service.
|
||||
* @param caps - the deployment's resolved glob caps (plugin config after defaulting).
|
||||
*/
|
||||
export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:glob',
|
||||
order: 103,
|
||||
text: 'Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'glob',
|
||||
description: '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 ${caps.maxResults} paths inline; a capped result reports where the complete list was saved.`,
|
||||
parameters: {
|
||||
pattern: { type: 'string', required: true, 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.' },
|
||||
},
|
||||
timeoutMs: caps.timeoutMs,
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseGlobArgs(args)
|
||||
const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes)
|
||||
if (run.noMatches) return [{ type: 'text', text: 'No files found' }]
|
||||
|
||||
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: caps.maxResults })
|
||||
const all: string[] = []
|
||||
for (const line of run.stdout.split('\n')) {
|
||||
if (line.length === 0) continue
|
||||
const displayPath = toWorkdirRelative(line, run.workdir)
|
||||
all.push(displayPath)
|
||||
retainer.push(displayPath)
|
||||
}
|
||||
const retained = retainer.finish()
|
||||
|
||||
// The complete sorted list is the recovery artifact; save it only when
|
||||
// the inline page omitted paths (an uncapped result needs no spill file).
|
||||
const spillRef = retained.truncated
|
||||
? await trySaveFormattedResult(ctx, exec, 'glob-results.txt', all.join('\n'))
|
||||
: undefined
|
||||
return [{ type: 'text', text: formatGlobOutput(retained, spillRef) }]
|
||||
},
|
||||
presentCall: presentGlobCall,
|
||||
}))
|
||||
}
|
||||
315
packages/fs/tool-fs-search/src/grep.ts
Normal file
315
packages/fs/tool-fs-search/src/grep.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* The model-facing `grep` tool: search file contents with a ripgrep regular
|
||||
* expression. Execution goes through the bash seam (`ctx.bash`) with a fixed
|
||||
* line-oriented `rg --json` command so file path, line number, and line text
|
||||
* parse without colon-splitting ambiguity — this module owns the model-facing
|
||||
* schema, argument validation, shell-safe command construction, `--json`
|
||||
* record parsing, per-line preview retention, match retention, grouping, and
|
||||
* formatting; process concerns stay behind `ctx.bash`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/grep
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
|
||||
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
|
||||
import type { SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type {} from '@deepseek-ai/dsh-bash'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
import { singleQuote } from './shell-quote.ts'
|
||||
|
||||
/**
|
||||
* Default cap on flat matches retained inline by one `grep` call (the
|
||||
* `grepMaxMatches` config), matching Claude Code's default `GrepTool`
|
||||
* `head_limit`.
|
||||
*/
|
||||
export const GREP_MAX_MATCHES = 250
|
||||
|
||||
/**
|
||||
* Default cap in bytes on one matched-line preview (the `grepMaxLineBytes`
|
||||
* config); the cut preserves UTF-8 boundaries.
|
||||
*/
|
||||
export const GREP_MAX_LINE_BYTES = 2000
|
||||
|
||||
/** Resolved grep-tool caps — plugin config after defaulting (see `Config` in index.ts). */
|
||||
export interface GrepToolCaps {
|
||||
/** Max flat matches retained inline; later matches go to the formatted spill file. */
|
||||
maxMatches: number
|
||||
/** Max bytes retained per matched-line preview. */
|
||||
maxLineBytes: number
|
||||
/** Cap on the complete raw `rg` stdout the tool will parse. */
|
||||
rawOutputMaxBytes: number
|
||||
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
/** Validated `grep` arguments. */
|
||||
export interface GrepInput {
|
||||
pattern: string
|
||||
path?: string
|
||||
include?: string
|
||||
}
|
||||
|
||||
/** One parsed match: the file, the 1-based line number, and the (possibly previewed) line text. */
|
||||
export interface GrepMatch {
|
||||
path: string
|
||||
lineNumber: number
|
||||
line: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an `include` that is not ONE positive glob filter: blank strings,
|
||||
* negated patterns (`!…`), and comma-separated lists. A comma inside a brace
|
||||
* group is fine — `*.{ts,tsx}` is one glob with alternation, not a list.
|
||||
*/
|
||||
function validateInclude(include: string): void {
|
||||
if (include.trim().length === 0) throw new Error('include must be a non-empty glob when given')
|
||||
if (include.startsWith('!')) throw new Error('include must be a positive glob filter; negated patterns ("!…") are not supported')
|
||||
let braceDepth = 0
|
||||
for (const char of include) {
|
||||
if (char === '{') braceDepth++
|
||||
else if (char === '}') braceDepth = Math.max(0, braceDepth - 1)
|
||||
else if (char === ',' && braceDepth === 0) {
|
||||
throw new Error('include must be one glob, not a comma-separated list (use {a,b} alternation instead)')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate value constraints the schema DSL can't express: a non-EMPTY
|
||||
* `pattern` (whitespace is a legitimate regex), a non-blank `path` when given,
|
||||
* and a single positive `include` glob ({@link GrepInput}). Throws a plain
|
||||
* `Error` (an ordinary tool argument error) otherwise.
|
||||
*
|
||||
* @param args - the schema-validated `grep` arguments.
|
||||
* @returns the accepted input, unchanged.
|
||||
*/
|
||||
export function parseGrepArgs(args: { pattern: string; path?: string; include?: string }): GrepInput {
|
||||
if (args.pattern.length === 0) throw new Error('pattern must be a non-empty string')
|
||||
if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given')
|
||||
if (args.include !== undefined) validateInclude(args.include)
|
||||
return {
|
||||
pattern: args.pattern,
|
||||
...args.path !== undefined ? { path: args.path } : {},
|
||||
...args.include !== undefined ? { include: args.include } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fixed line-oriented `rg --json` command for one `grep` call. Every
|
||||
* model-controlled value ({@link GrepInput.pattern}, {@link GrepInput.path},
|
||||
* {@link GrepInput.include}) passes through {@link singleQuote}; the pattern
|
||||
* and include ride in `--flag=value` form and the target behind `--`, so a
|
||||
* leading-dash value can never be parsed as a flag.
|
||||
*
|
||||
* @param input - the validated arguments.
|
||||
* @returns the complete, shell-safe command string.
|
||||
*/
|
||||
export function buildGrepCommand(input: GrepInput): string {
|
||||
const parts = ['rg --json', `--regexp=${singleQuote(input.pattern)}`]
|
||||
if (input.include !== undefined) parts.push(`--glob=${singleQuote(input.include)}`)
|
||||
if (input.path !== undefined) parts.push('--', singleQuote(input.path))
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* The uniform malformed-output failure: raw `rg --json` is an internal
|
||||
* transport, so a shape surprise is a search failure, not a partial result.
|
||||
*/
|
||||
function malformedRecord(detail: string, cause?: unknown): SearchError {
|
||||
return new SearchError(`grep received malformed ripgrep --json output (${detail})`, 'SEARCH_FAILED', cause !== undefined ? { cause } : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one `rg --json` NDJSON line into a match, `undefined` for the
|
||||
* non-match record types (`begin`/`end`/`context`/`summary`). A line that is
|
||||
* not JSON, or a `match` record missing its path / line number / line content,
|
||||
* throws {@link SearchError} `SEARCH_FAILED`. A match whose line is not valid
|
||||
* UTF-8 (ripgrep sends base64 `bytes` instead of `text`) yields a placeholder
|
||||
* preview rather than failing the whole search.
|
||||
*/
|
||||
function parseRecord(line: string): GrepMatch | undefined {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
} catch (error: unknown) {
|
||||
throw malformedRecord('a line is not JSON', error)
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) throw malformedRecord('a record is not an object')
|
||||
const record = parsed as { type?: unknown; data?: unknown }
|
||||
// Non-match record types (begin/end/context/summary — and any future type)
|
||||
// are transport framing, not results: skipped, not malformed.
|
||||
if (record.type !== 'match') return undefined
|
||||
if (typeof record.data !== 'object' || record.data === null) throw malformedRecord('a match record has no data')
|
||||
const data = record.data as { path?: unknown; line_number?: unknown; lines?: unknown }
|
||||
const pathText = typeof data.path === 'object' && data.path !== null ? (data.path as { text?: unknown }).text : undefined
|
||||
if (typeof pathText !== 'string') throw malformedRecord('a match record has no path text')
|
||||
if (typeof data.line_number !== 'number') throw malformedRecord('a match record has no line number')
|
||||
if (typeof data.lines !== 'object' || data.lines === null) throw malformedRecord('a match record has no line content')
|
||||
const lines = data.lines as { text?: unknown; bytes?: unknown }
|
||||
if (typeof lines.text === 'string') {
|
||||
return { path: pathText, lineNumber: data.line_number, line: lines.text.replace(/\r?\n$/, '') }
|
||||
}
|
||||
if (typeof lines.bytes === 'string') {
|
||||
return { path: pathText, lineNumber: data.line_number, line: '(line is not valid UTF-8)' }
|
||||
}
|
||||
throw malformedRecord('a match record has neither line text nor bytes')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse complete `rg --json` stdout into flat matches, in output order (ripgrep
|
||||
* emits one file's matches contiguously). Only `match` records are consumed.
|
||||
*
|
||||
* @param stdout - the complete raw `rg --json` stdout.
|
||||
* @returns the flat matches; empty for output with no match records.
|
||||
*/
|
||||
export function parseGrepMatches(stdout: string): GrepMatch[] {
|
||||
const matches: GrepMatch[] = []
|
||||
for (const line of stdout.split('\n')) {
|
||||
if (line.length === 0) continue
|
||||
const match = parseRecord(line)
|
||||
if (match !== undefined) matches.push(match)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and
|
||||
* mark the cut. The cap is a per-line budget fact; the complete line stays in
|
||||
* the searched file for `read`.
|
||||
*
|
||||
* @param line - the matched line text (trailing newline already stripped).
|
||||
* @param maxBytes - the preview budget in bytes.
|
||||
* @returns the preview, suffixed with ` (line truncated)` when bytes were cut.
|
||||
*/
|
||||
export function previewLine(line: string, maxBytes: number): string {
|
||||
const retainer = new TextRetainer({ kind: 'head', maxBytes })
|
||||
retainer.push(line)
|
||||
const kept = retainer.finish()
|
||||
return kept.truncated ? `${kept.text} (line truncated)` : kept.text
|
||||
}
|
||||
|
||||
/** `match` / `matches` for a count. */
|
||||
function matchNoun(count: number): string {
|
||||
return count === 1 ? 'match' : 'matches'
|
||||
}
|
||||
|
||||
/**
|
||||
* Group flat matches by file (first-seen order) into the model-facing body:
|
||||
* each file's display path, then one `Line N: <text>` row per match.
|
||||
*
|
||||
* @param matches - the flat matches to render.
|
||||
* @returns the grouped body text.
|
||||
*/
|
||||
export function formatGrepMatches(matches: GrepMatch[]): string {
|
||||
const byFile = new Map<string, GrepMatch[]>()
|
||||
for (const match of matches) {
|
||||
const group = byFile.get(match.path)
|
||||
if (group !== undefined) group.push(match)
|
||||
else byFile.set(match.path, [match])
|
||||
}
|
||||
const sections: string[] = []
|
||||
for (const [path, group] of byFile) {
|
||||
sections.push(`${path}\n${group.map(m => `Line ${m.lineNumber}: ${m.line}`).join('\n')}`)
|
||||
}
|
||||
return sections.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the model-facing `grep` result: a found-count header, the retained
|
||||
* matches grouped by file, then — when the result was capped — a footer
|
||||
* carrying either the formatted-spill recovery locator or the could-not-save
|
||||
* explanation. The omitted count is a budget fact: the search itself completed.
|
||||
*
|
||||
* @param retained - the retention outcome over every parsed match.
|
||||
* @param spillRef - the saved complete-result reference, or `undefined` when unsaved.
|
||||
* @returns the model-facing text.
|
||||
*/
|
||||
export function formatGrepOutput(retained: RetainedItems<GrepMatch>, spillRef: SpillRef | undefined): string {
|
||||
const header = retained.truncated
|
||||
? `Found ${retained.kept} of ${retained.seen} matches`
|
||||
: `Found ${retained.seen} ${matchNoun(retained.seen)}`
|
||||
const body = formatGrepMatches(retained.items)
|
||||
if (!retained.truncated) return `${header}\n\n${body}`
|
||||
const recovery = spillRef !== undefined
|
||||
? `Full grep result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`
|
||||
: 'The complete result could not be saved; narrow pattern, path, or include to see more.'
|
||||
return `${header}\n\n${body}\n\n(${recovery})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending-call presentation: a search card titled by the pattern (and target /
|
||||
* include filter).
|
||||
*
|
||||
* @param args - the raw tool arguments; `pattern`, `path`, and `include` feed the title.
|
||||
* @returns the generic card view (`kind: 'search'`) shown while the call runs.
|
||||
*/
|
||||
export function presentGrepCall(args: { pattern: string; path?: string; include?: string }): GenericCallView {
|
||||
const where = args.path !== undefined ? ` in ${args.path}` : ''
|
||||
const filter = args.include !== undefined ? ` (${args.include})` : ''
|
||||
return { card: 'generic', title: `Grep ${args.pattern}${where}${filter}`, kind: 'search', rawInput: args.pattern }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `grep` tool and its system-prompt guidance.
|
||||
*
|
||||
* @param ctx - the plugin context; registrations are effects scoped to it, and
|
||||
* execution uses its `bash` service.
|
||||
* @param caps - the deployment's resolved grep caps (plugin config after defaulting).
|
||||
*/
|
||||
export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:grep',
|
||||
order: 104,
|
||||
text: 'Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'grep',
|
||||
description: 'Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. '
|
||||
+ `Returns the first ${caps.maxMatches} matches inline; a capped result reports where the complete match list was saved. `
|
||||
+ 'Use read on a matched file for surrounding context.',
|
||||
parameters: {
|
||||
pattern: { type: 'string', required: true, 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.' },
|
||||
},
|
||||
timeoutMs: caps.timeoutMs,
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseGrepArgs(args)
|
||||
const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes)
|
||||
if (run.noMatches) return [{ type: 'text', text: 'No matches found' }]
|
||||
|
||||
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: caps.maxMatches })
|
||||
const all: GrepMatch[] = []
|
||||
for (const raw of parseGrepMatches(run.stdout)) {
|
||||
const match: GrepMatch = {
|
||||
path: toWorkdirRelative(raw.path, run.workdir),
|
||||
lineNumber: raw.lineNumber,
|
||||
line: previewLine(raw.line, caps.maxLineBytes),
|
||||
}
|
||||
all.push(match)
|
||||
retainer.push(match)
|
||||
}
|
||||
const retained = retainer.finish()
|
||||
|
||||
// The spill file stores the FULL formatted match list (same grouped,
|
||||
// per-line-previewed shape the model saw), so read offset/limit pages the
|
||||
// same logical result; save only when the inline page omitted matches.
|
||||
const spillRef = retained.truncated
|
||||
? await trySaveFormattedResult(
|
||||
ctx,
|
||||
exec,
|
||||
'grep-results.txt',
|
||||
`Found ${all.length} ${matchNoun(all.length)}\n\n${formatGrepMatches(all)}`,
|
||||
)
|
||||
: undefined
|
||||
return [{ type: 'text', text: formatGrepOutput(retained, spillRef) }]
|
||||
},
|
||||
presentCall: presentGrepCall,
|
||||
}))
|
||||
}
|
||||
110
packages/fs/tool-fs-search/src/index.ts
Normal file
110
packages/fs/tool-fs-search/src/index.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* The model-facing filesystem discovery tool suite (`glob`, `grep`) over the
|
||||
* bash executor seam (`ctx.bash`). This single plugin registers both tools.
|
||||
*
|
||||
* ## Bash-backed, not a `ctx.fs` provider method
|
||||
*
|
||||
* Local workspace discovery is a process-backed `rg` workflow, so these tools
|
||||
* execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` with fixed
|
||||
* ripgrep command templates — never `ctx.bash.start()`, never a model-visible
|
||||
* background task. The tool layer owns schemas, argument validation, shell
|
||||
* quoting ({@link module:@deepseek-ai/dsh-tool-fs-search/shell-quote}), result
|
||||
* parsing, retention, formatted-result spill, and timeout declaration; the
|
||||
* bash executor owns request defaulting/capping, subprocess execution,
|
||||
* process-group termination, environment scrubbing, raw output capture, and
|
||||
* backend substitution. The package injects `tools`, `systemPrompt`, and
|
||||
* `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read opportunistically
|
||||
* with `ctx.get()` because formatted-result spill is optional.
|
||||
*
|
||||
* Returned paths are displayed relative to the resolved bash workdir and are
|
||||
* follow-up-readable only in co-located deployments where the bash workdir and
|
||||
* the filesystem `read` root are the same workspace — a documented v1
|
||||
* deployment requirement, not runtime-validated.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts'
|
||||
import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts'
|
||||
import { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
|
||||
|
||||
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall } from './glob.ts'
|
||||
export type { GlobInput, GlobToolCaps } from './glob.ts'
|
||||
export {
|
||||
GREP_MAX_LINE_BYTES,
|
||||
GREP_MAX_MATCHES,
|
||||
applyGrepTool,
|
||||
buildGrepCommand,
|
||||
formatGrepMatches,
|
||||
formatGrepOutput,
|
||||
parseGrepArgs,
|
||||
parseGrepMatches,
|
||||
presentGrepCall,
|
||||
previewLine,
|
||||
} from './grep.ts'
|
||||
export type { GrepInput, GrepMatch, GrepToolCaps } from './grep.ts'
|
||||
export { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS, SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
export type { RipgrepRun, SearchErrorCode } from './search-core.ts'
|
||||
export { singleQuote } from './shell-quote.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'tool-fs-search'
|
||||
|
||||
/** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */
|
||||
export const inject = ['tools', 'systemPrompt', 'bash']
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
globMaxResults: z.number().default(GLOB_MAX_RESULTS),
|
||||
grepMaxMatches: z.number().default(GREP_MAX_MATCHES),
|
||||
grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES),
|
||||
rawOutputMaxBytes: z.number().default(RAW_OUTPUT_MAX_BYTES),
|
||||
timeoutMs: z.number().default(SEARCH_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
/** The shape after schemastery applied the defaults. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** Every search cap counts items/bytes/milliseconds — a positive integer, or retention and timeout arithmetic misbehaves silently. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`tool-fs-search: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the `glob`/`grep` filesystem discovery tool suite. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// schemastery (Config) has already filled every defaulted field.
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveInteger('globMaxResults', resolved.globMaxResults)
|
||||
assertPositiveInteger('grepMaxMatches', resolved.grepMaxMatches)
|
||||
assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes)
|
||||
assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes)
|
||||
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
|
||||
applyGlobTool(ctx, {
|
||||
maxResults: resolved.globMaxResults,
|
||||
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
})
|
||||
applyGrepTool(ctx, {
|
||||
maxMatches: resolved.grepMaxMatches,
|
||||
maxLineBytes: resolved.grepMaxLineBytes,
|
||||
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
})
|
||||
}
|
||||
262
packages/fs/tool-fs-search/src/search-core.ts
Normal file
262
packages/fs/tool-fs-search/src/search-core.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Shared execution plumbing for the `glob` / `grep` search tools: the
|
||||
* package-owned `SEARCH_*` error vocabulary, one bash-seam run helper that
|
||||
* turns a fixed `rg` command into complete raw stdout, the best-effort
|
||||
* formatted-result spill handoff, and workdir-relative path display.
|
||||
*
|
||||
* Both tools execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)`
|
||||
* as ordinary foreground tool calls — never `ctx.bash.start()`, never a
|
||||
* model-visible background task. Raw `rg` stdout is an internal transport
|
||||
* detail: the tools request a per-run stdout capture budget from the bash seam,
|
||||
* parse only complete in-memory stdout within `rawOutputMaxBytes`, and never
|
||||
* read executor spill files. The model-facing recovery artifact is the
|
||||
* formatted result saved through `ctx.spillStore.saveText()`
|
||||
* ({@link trySaveFormattedResult}).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/search-core
|
||||
*/
|
||||
|
||||
import { isAbsolute, relative, sep } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/**
|
||||
* Default cap on the complete raw `rg` stdout the tools will parse (the
|
||||
* `rawOutputMaxBytes` config), matching Claude Code's ripgrep raw buffer.
|
||||
*/
|
||||
export const RAW_OUTPUT_MAX_BYTES = 20_000_000
|
||||
|
||||
/**
|
||||
* Default cooperative tool-call timeout budget in milliseconds (the `timeoutMs`
|
||||
* config), attached to both tool definitions for
|
||||
* `@deepseek-ai/dsh-timeout-policy` to enforce through `exec.signal`.
|
||||
*/
|
||||
export const SEARCH_TIMEOUT_MS = 30_000
|
||||
|
||||
/**
|
||||
* Stable, machine-routable codes for search failures. Package-owned (not
|
||||
* `FsErrorCode`) because these tools are bash-backed discovery, not `ctx.fs`
|
||||
* provider operations: `SEARCH_INVALID_PATTERN` — ripgrep rejected the regex or
|
||||
* glob; `SEARCH_FAILED` — the search could not run or its output could not be
|
||||
* parsed (missing `rg`, inaccessible target, signal kill, malformed `--json`);
|
||||
* `SEARCH_RAW_OUTPUT_OVERFLOW` — raw `rg` output exceeded `rawOutputMaxBytes`
|
||||
* or stayed truncated after that requested stdout budget; `SEARCH_ABORTED` — the tool
|
||||
* timeout, caller cancellation, or the bash executor's own timeout cut the
|
||||
* search short.
|
||||
*/
|
||||
export type SearchErrorCode =
|
||||
| 'SEARCH_INVALID_PATTERN'
|
||||
| 'SEARCH_FAILED'
|
||||
| 'SEARCH_RAW_OUTPUT_OVERFLOW'
|
||||
| 'SEARCH_ABORTED'
|
||||
|
||||
/**
|
||||
* Typed search failure. Extends {@link HarnessError} so it carries a stable
|
||||
* {@link SearchErrorCode} and chains `cause`; the tool registry surfaces
|
||||
* `{ name, code }` on `isError` results so retry/permission/UI layers can
|
||||
* branch without parsing messages.
|
||||
*/
|
||||
export class SearchError extends HarnessError {
|
||||
override readonly code: SearchErrorCode
|
||||
|
||||
constructor(message: string, code: SearchErrorCode, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
/** The completed acquisition of one `rg` run: complete stdout plus the resolved workdir. */
|
||||
export interface RipgrepRun {
|
||||
/** Complete raw stdout retained by the bash executor within the requested cap. */
|
||||
stdout: string
|
||||
/** True when ripgrep exited 1: a successful search with zero results. */
|
||||
noMatches: boolean
|
||||
/** The resolved working directory the command ran in (the display-relativization base). */
|
||||
workdir: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The retained stderr tail as a diagnostic excerpt, with a truncation note when
|
||||
* the executor dropped bytes (the tool never reads `stderr.spillPath`).
|
||||
*/
|
||||
function stderrExcerpt(stderr: CollectedOutput): string {
|
||||
const text = stderr.text.trim()
|
||||
if (text.length === 0) return ''
|
||||
return stderr.truncated ? `${text} [stderr truncated]` : text
|
||||
}
|
||||
|
||||
/** Classify a nonzero-exit `rg` run into the search error vocabulary (invalid pattern vs missing `rg` vs everything else). */
|
||||
function classifyRunFailure(toolName: string, result: BashRunResult): SearchError {
|
||||
const stderr = stderrExcerpt(result.stderr)
|
||||
if (/regex parse error|error parsing glob/i.test(stderr)) {
|
||||
return new SearchError(`${toolName} pattern rejected by ripgrep: ${stderr}`, 'SEARCH_INVALID_PATTERN')
|
||||
}
|
||||
if (result.exitCode === 127 || /command not found/i.test(stderr)) {
|
||||
return new SearchError(`${toolName} requires ripgrep (rg) on the bash executor's PATH${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
|
||||
}
|
||||
return new SearchError(`${toolName} search failed (exit ${result.exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the COMPLETE raw stdout of a finished run, enforcing
|
||||
* `rawOutputMaxBytes` on the in-memory transport. A truncated result means the
|
||||
* bash backend could not retain complete stdout within the requested budget, so
|
||||
* the tool fails clearly instead of parsing a silently-partial stream.
|
||||
*/
|
||||
function completeStdout(toolName: string, result: BashRunResult, rawOutputMaxBytes: number): string {
|
||||
const narrow = 'narrow pattern, path, or include and retry'
|
||||
if (!result.stdout.truncated) {
|
||||
const inlineBytes = Buffer.byteLength(result.stdout.text, 'utf8')
|
||||
if (inlineBytes > rawOutputMaxBytes) {
|
||||
throw new SearchError(
|
||||
`${toolName} produced ${inlineBytes} bytes of raw output, over the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
|
||||
'SEARCH_RAW_OUTPUT_OVERFLOW',
|
||||
)
|
||||
}
|
||||
return result.stdout.text
|
||||
}
|
||||
throw new SearchError(
|
||||
`${toolName} produced more raw output than the bash executor retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
|
||||
'SEARCH_RAW_OUTPUT_OVERFLOW',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one fixed `rg` command through the bash seam and return its complete raw
|
||||
* stdout. The bash request workdir is the calling agent's session cwd
|
||||
* (`exec.agent.session.header.cwd`) when available — mirroring `dsh-tool-bash` /
|
||||
* `dsh-tool-fs` — else omitted so the implementation's `resolve()` applies its
|
||||
* configured default. `exec.signal` is forwarded so the cooperative tool
|
||||
* timeout (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation kill the
|
||||
* command; the bash backend's own timeout stays a second safety cap.
|
||||
*
|
||||
* Exit semantics are tool-owned: exit 0 is success with results, exit 1 is
|
||||
* success with zero results (`noMatches`), anything else throws a
|
||||
* {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern →
|
||||
* `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` /
|
||||
* `SEARCH_RAW_OUTPUT_OVERFLOW`). A `run()` REJECTION — the seam's
|
||||
* infrastructure failures (pre-aborted signal, unusable workdir, missing
|
||||
* shell) — is translated into the same taxonomy: a pre-aborted signal becomes
|
||||
* `SEARCH_ABORTED`, everything else `SEARCH_FAILED`, with the original as
|
||||
* `cause`.
|
||||
*
|
||||
* @param ctx - the plugin context; execution uses its `bash` service.
|
||||
* @param exec - the tool-execution context; supplies the session cwd and the abort signal.
|
||||
* @param toolName - `glob` or `grep`, used in error messages.
|
||||
* @param command - the fully-quoted `rg` command string (every model value already through `singleQuote`).
|
||||
* @param rawOutputMaxBytes - cap on the complete raw stdout the tool will parse.
|
||||
* @returns the complete stdout, the zero-result flag, and the resolved workdir.
|
||||
*/
|
||||
export async function runRipgrep(
|
||||
ctx: Context,
|
||||
exec: ToolExecution,
|
||||
toolName: string,
|
||||
command: string,
|
||||
rawOutputMaxBytes: number,
|
||||
): Promise<RipgrepRun> {
|
||||
const cwd = exec.agent?.session.header.cwd
|
||||
const spec = ctx.bash.resolve({
|
||||
command,
|
||||
stdoutMaxBytes: rawOutputMaxBytes,
|
||||
...cwd !== undefined ? { workdir: cwd } : {},
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
})
|
||||
let result: BashRunResult
|
||||
try {
|
||||
result = await ctx.bash.run(spec)
|
||||
} catch (error: unknown) {
|
||||
// The seam contract: run() REJECTS only for infrastructure failures — a
|
||||
// pre-aborted signal, an unusable workdir, a missing shell. Translate them
|
||||
// so these failures stay machine-routable under the SEARCH_* taxonomy.
|
||||
if (spec.signal?.aborted === true) {
|
||||
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED', { cause: error })
|
||||
}
|
||||
throw new SearchError(`${toolName} could not start its search command (unusable working directory or missing shell)`, 'SEARCH_FAILED', { cause: error })
|
||||
}
|
||||
if (result.aborted) {
|
||||
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED')
|
||||
}
|
||||
if (result.timedOut) {
|
||||
throw new SearchError(`${toolName} timed out after ${result.timeoutMs}ms in the bash executor; narrow pattern, path, or include and retry`, 'SEARCH_ABORTED')
|
||||
}
|
||||
if (result.signal !== null || result.exitCode === null) {
|
||||
throw new SearchError(`${toolName} search command was killed by signal ${result.signal ?? '(unknown)'}`, 'SEARCH_FAILED')
|
||||
}
|
||||
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
||||
throw classifyRunFailure(toolName, result)
|
||||
}
|
||||
const stdout = completeStdout(toolName, result, rawOutputMaxBytes)
|
||||
return { stdout, noMatches: result.exitCode === 1, workdir: spec.workdir }
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an `rg` output path to its display form: absolute paths inside the
|
||||
* resolved bash workdir become workdir-relative; everything else (relative
|
||||
* output, paths outside the workdir) passes through unchanged. Display-only —
|
||||
* returned paths are follow-up-readable in co-located bash/filesystem
|
||||
* deployments where both resolve the same workspace (the documented v1
|
||||
* deployment requirement).
|
||||
*
|
||||
* @param path - one path as ripgrep printed it.
|
||||
* @param workdir - the resolved bash workdir the command ran in.
|
||||
* @returns the workdir-relative display path when possible, else `path` unchanged.
|
||||
*/
|
||||
export function toWorkdirRelative(path: string, workdir: string): string {
|
||||
if (!isAbsolute(path)) return path
|
||||
const rel = relative(workdir, path)
|
||||
if (rel.length === 0) return '.'
|
||||
if (rel === '..' || rel.startsWith(`..${sep}`)) return path
|
||||
return rel
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort save of one COMPLETE formatted search result through
|
||||
* `ctx.spillStore.saveText()` — the model-facing recovery path for a capped
|
||||
* result. `spillStore` is read with `ctx.get()` (not static inject) because
|
||||
* formatted-result spill is optional; the spill owner is the calling agent's
|
||||
* session header id and the source is the tool execution identity. A missing
|
||||
* backend, a call with no session owner, or a `saveText()` rejection logs a
|
||||
* warning and returns `undefined` — the caller keeps the inline result and
|
||||
* reports that the complete result could not be saved; search success never
|
||||
* turns into `isError` because spill storage is unavailable.
|
||||
*
|
||||
* @param ctx - the plugin context; `spillStore` is looked up opportunistically.
|
||||
* @param exec - the tool-execution context; supplies the owning session, tool name, and call id.
|
||||
* @param suggestedName - the backend-sanitized filename hint (e.g. `grep-results.txt`).
|
||||
* @param content - the complete formatted result to persist.
|
||||
* @returns the saved spill reference, or `undefined` when the result could not be saved.
|
||||
*/
|
||||
export async function trySaveFormattedResult(
|
||||
ctx: Context,
|
||||
exec: ToolExecution,
|
||||
suggestedName: string,
|
||||
content: string,
|
||||
): Promise<SpillRef | undefined> {
|
||||
const sessionId = exec.agent?.session.header.id
|
||||
if (sessionId === undefined) {
|
||||
ctx.logger.warn(`tool-fs-search: no session owner for ${exec.name} result; complete result not saved`)
|
||||
return undefined
|
||||
}
|
||||
const spillStore = ctx.get('spillStore')
|
||||
if (!spillStore) {
|
||||
ctx.logger.warn(`tool-fs-search: no ctx.spillStore backend loaded; complete ${exec.name} result not saved`)
|
||||
return undefined
|
||||
}
|
||||
const save: SaveTextSpill = {
|
||||
owner: { sessionId },
|
||||
source: { toolName: exec.name, callId: exec.callId, label: 'result' },
|
||||
suggestedName,
|
||||
content,
|
||||
}
|
||||
try {
|
||||
return await spillStore.saveText(save)
|
||||
} catch (error: unknown) {
|
||||
// Best-effort: a storage failure must never fail the search or hide the
|
||||
// inline result — the footer reports the unsaved remainder instead.
|
||||
ctx.logger.warn(`tool-fs-search: saveText failed for ${exec.name}: ${String(error)}; complete result not saved`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
27
packages/fs/tool-fs-search/src/shell-quote.ts
Normal file
27
packages/fs/tool-fs-search/src/shell-quote.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The one shell-quoting helper both search tools MUST route every
|
||||
* model-controlled value through before it enters an `rg` command string. The
|
||||
* bash seam (`ctx.bash`) accepts a command STRING, not an argv vector, so this
|
||||
* is the safety boundary that stops a `pattern`, `path`, or `include` from
|
||||
* breaking out of its argument and injecting shell syntax.
|
||||
*
|
||||
* Command builders in `glob.ts` / `grep.ts` must never hand-roll quoting or
|
||||
* concatenate an unquoted model value — they call {@link singleQuote}.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/shell-quote
|
||||
*/
|
||||
|
||||
/**
|
||||
* POSIX single-quote a string for safe use as ONE shell word. Wraps the value
|
||||
* in single quotes and rewrites every embedded single quote as `'\''` (close
|
||||
* quote, an escaped literal quote, reopen quote). Inside single quotes the shell
|
||||
* treats every other byte literally — spaces, newlines, `$`, backticks, `;`,
|
||||
* `|`, `&`, glob metacharacters, and a leading `-` are all inert — so the result
|
||||
* is a single, injection-safe argument regardless of the input.
|
||||
*
|
||||
* @param value - the raw, possibly model-controlled string to quote.
|
||||
* @returns the value wrapped as one safe single-quoted shell word.
|
||||
*/
|
||||
export function singleQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
190
packages/fs/tool-fs-search/tests/integration.spec.ts
Normal file
190
packages/fs/tool-fs-search/tests/integration.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Integration tests: the REAL local bash executor (`dsh-bash-local`) plus a
|
||||
* REAL ripgrep binary, exercised through `ctx.tools.execute()`. These verify
|
||||
* the WORLD — actual files on disk are discovered and grepped, hostile
|
||||
* patterns stay inert in a real shell, and real `rg` stderr classifies into
|
||||
* the `SEARCH_*` vocabulary. The whole suite self-skips when `rg` is not on
|
||||
* PATH (a CI accommodation mirroring the keyless e2e skip); the fake-executor
|
||||
* suite (tools.spec.ts) carries the coverage gate.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
const hasRg = spawnSync('rg', ['--version'], { encoding: 'utf8' }).status === 0
|
||||
|
||||
let dir: string
|
||||
let ctx: Context
|
||||
|
||||
let callCounter = 0
|
||||
function call(name: string, args: unknown, agentObj?: object) {
|
||||
return ctx.tools.execute({
|
||||
callId: CallId(`it-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...agentObj ? { agent: agentObj as never } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () => {
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-search-int-'))
|
||||
await mkdir(join(dir, 'src'), { recursive: true })
|
||||
await mkdir(join(dir, '.git'), { recursive: true })
|
||||
await mkdir(join(dir, 'spaced dir'), { recursive: true })
|
||||
await writeFile(join(dir, 'src', 'alpha.ts'), 'export const alpha = 1\n// TODO: refit alpha\n')
|
||||
await writeFile(join(dir, 'src', 'beta.ts'), 'export const beta = 2\n')
|
||||
await writeFile(join(dir, 'notes.md'), 'alpha appears here too\n')
|
||||
await writeFile(join(dir, '.hidden.ts'), 'export const hidden = 3\n')
|
||||
await writeFile(join(dir, '.git', 'config.ts'), 'never listed\n')
|
||||
await writeFile(join(dir, 'spaced dir', "wei'rd \"name\".ts"), 'const inside = true\n')
|
||||
// Deterministic --sort=modified order: alpha oldest, beta newest.
|
||||
await utimes(join(dir, 'src', 'alpha.ts'), new Date(2000, 0, 1), new Date(2000, 0, 1))
|
||||
await utimes(join(dir, 'src', 'beta.ts'), new Date(2020, 0, 1), new Date(2020, 0, 1))
|
||||
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 })
|
||||
await ctx.plugin(ToolFsSearch)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('glob', () => {
|
||||
it('discovers files by pattern, sorted by modification time, hidden included, .git excluded', async () => {
|
||||
const result = await call('glob', { pattern: '**/*.ts' })
|
||||
expect(result.isError).toBe(false)
|
||||
const paths = text(result).split('\n')
|
||||
expect(paths.indexOf('src/alpha.ts')).toBeLessThan(paths.indexOf('src/beta.ts'))
|
||||
expect(paths).toContain('.hidden.ts')
|
||||
expect(paths).toContain("spaced dir/wei'rd \"name\".ts")
|
||||
expect(paths).not.toContain('.git/config.ts')
|
||||
expect(paths).not.toContain('notes.md')
|
||||
})
|
||||
|
||||
it('scopes to a directory search root (path arg)', async () => {
|
||||
const result = await call('glob', { pattern: '*.ts', path: 'src' })
|
||||
expect(text(result).split('\n').sort()).toEqual(['src/alpha.ts', 'src/beta.ts'])
|
||||
})
|
||||
|
||||
it('reports zero discoveries as No files found', async () => {
|
||||
expect(text(await call('glob', { pattern: '*.nomatch' }))).toBe('No files found')
|
||||
})
|
||||
|
||||
it('excludes VCS internals even when the search root IS the VCS directory', async () => {
|
||||
// The prune glob alone never matches root-prefixed paths when rg is
|
||||
// rooted at .git; the paired contents glob keeps the exclusion airtight.
|
||||
expect(text(await call('glob', { pattern: '*', path: '.git' }))).toBe('No files found')
|
||||
})
|
||||
|
||||
it('classifies an invalid glob as SEARCH_INVALID_PATTERN', async () => {
|
||||
const result = await call('glob', { pattern: '[' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('grep', () => {
|
||||
it('greps a directory tree with grouped, line-numbered output', async () => {
|
||||
const result = await call('grep', { pattern: 'alpha' })
|
||||
expect(result.isError).toBe(false)
|
||||
const output = text(result)
|
||||
expect(output).toContain('Found 3 matches')
|
||||
expect(output).toContain('src/alpha.ts\nLine 1: export const alpha = 1\nLine 2: // TODO: refit alpha')
|
||||
expect(output).toContain('notes.md\nLine 1: alpha appears here too')
|
||||
})
|
||||
|
||||
it('greps a single FILE target', async () => {
|
||||
const result = await call('grep', { pattern: 'alpha', path: 'notes.md' })
|
||||
expect(text(result)).toBe('Found 1 match\n\nnotes.md\nLine 1: alpha appears here too')
|
||||
})
|
||||
|
||||
it('greps a directory target with an include filter', async () => {
|
||||
const result = await call('grep', { pattern: 'alpha', path: '.', include: '*.ts' })
|
||||
const output = text(result)
|
||||
expect(output).toContain('alpha.ts')
|
||||
expect(output).not.toContain('notes.md')
|
||||
})
|
||||
|
||||
it('a hostile pattern stays inert (no command substitution, the world untouched)', async () => {
|
||||
const canary = join(dir, 'pwned')
|
||||
const result = await call('grep', { pattern: `$(touch ${canary})` })
|
||||
expect(result.isError).toBe(false) // exit 1: found nothing, executed nothing
|
||||
expect(text(result)).toBe('No matches found')
|
||||
expect(spawnSync('test', ['-e', canary]).status).not.toBe(0)
|
||||
})
|
||||
|
||||
it('a leading-dash pattern is a pattern, not a flag', async () => {
|
||||
await writeFile(join(dir, 'dashes.txt'), 'value --flag value\n')
|
||||
const result = await call('grep', { pattern: '--flag', path: 'dashes.txt' })
|
||||
expect(text(result)).toBe('Found 1 match\n\ndashes.txt\nLine 1: value --flag value')
|
||||
})
|
||||
|
||||
it('classifies a real rg regex error as SEARCH_INVALID_PATTERN', async () => {
|
||||
const result = await call('grep', { pattern: '(unclosed' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' })
|
||||
})
|
||||
|
||||
it('classifies a missing target as SEARCH_FAILED', async () => {
|
||||
const result = await call('grep', { pattern: 'x', path: 'no-such-dir' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-session cwd', () => {
|
||||
it('resolves the search in the SESSION workspace, not the executor config cwd', async () => {
|
||||
const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-search-session-'))
|
||||
try {
|
||||
await writeFile(join(sessionDir, 'only-here.ts'), 'const sessionFile = true\n')
|
||||
const agentObj = { session: { header: { id: 'session-int', cwd: sessionDir } } }
|
||||
const globbed = await call('glob', { pattern: '*.ts' }, agentObj)
|
||||
expect(text(globbed)).toBe('only-here.ts')
|
||||
const grepped = await call('grep', { pattern: 'sessionFile' }, agentObj)
|
||||
expect(text(grepped)).toContain('only-here.ts\nLine 1: const sessionFile = true')
|
||||
} finally {
|
||||
await rm(sessionDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('bash-start infrastructure failures stay in the SEARCH_* taxonomy', () => {
|
||||
it('a pre-aborted exec.signal (real executor rejects before spawn) is SEARCH_ABORTED', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId(`it-${++callCounter}`),
|
||||
name: 'grep',
|
||||
arguments: { pattern: 'x' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
|
||||
})
|
||||
|
||||
it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => {
|
||||
const gone = join(dir, 'deleted-session-dir')
|
||||
const result = await call('glob', { pattern: '*' }, { session: { header: { id: 'session-int', cwd: gone } } })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
|
||||
expect(text(result)).toContain('could not start')
|
||||
})
|
||||
})
|
||||
})
|
||||
50
packages/fs/tool-fs-search/tests/load-path.spec.ts
Normal file
50
packages/fs/tool-fs-search/tests/load-path.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Real-load-path guard for @deepseek-ai/dsh-tool-fs-search. `tool-fs-search` is
|
||||
* a NAMESPACE plugin with `inject` — so a stray `export default apply` would
|
||||
* make the cordis Loader's `unwrapExports` (`exports.default ?? exports`)
|
||||
* collapse the module to the bare `apply` function, DROPPING `inject`. The
|
||||
* plugin would then read `ctx.bash` without having injected it and throw
|
||||
* `cannot get property … without inject` the moment it loads (postmortem 0001).
|
||||
*
|
||||
* A hand-built `ctx.plugin({ apply, inject })` mount CANNOT catch that — it
|
||||
* bypasses `unwrapExports`. So this test unwraps the module through the REAL
|
||||
* `Loader.prototype.unwrapExports` and mounts the result over a bash executor,
|
||||
* exercising the exact path the Loader uses. Prove the guard bites: add
|
||||
* `export default apply` to `src/index.ts`, watch this go red, revert.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
describe('dsh-tool-fs-search real-load-path guard', () => {
|
||||
it('has no default export and keeps name/inject/Config through unwrapExports', () => {
|
||||
expect('default' in toolFsSearch).toBe(false)
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(toolFsSearch) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(toolFsSearch)
|
||||
expect(unwrapped.name).toBe('tool-fs-search')
|
||||
expect(unwrapped.inject).toEqual(['tools', 'systemPrompt', 'bash'])
|
||||
expect(typeof unwrapped.Config).toBe('function')
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
|
||||
it('boots over ctx.bash through the unwrapped module without an inject error', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters<Context['plugin']>[0]
|
||||
// A collapsed export shape (dropped inject) would throw "without inject" here.
|
||||
const fiber = await ctx.plugin(unwrapped)
|
||||
expect(ctx.tools.schemas().map(s => s.name)).toEqual(expect.arrayContaining(['glob', 'grep']))
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
59
packages/fs/tool-fs-search/tests/shell-quote.spec.ts
Normal file
59
packages/fs/tool-fs-search/tests/shell-quote.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Unit tests for the shell-quoting safety boundary, plus a REAL round-trip:
|
||||
* every adversarial value, quoted, must survive `bash -c "printf '%s' <quoted>"`
|
||||
* byte-for-byte — proving the quoting is inert in an actual shell, not just
|
||||
* against a mental model of one.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { singleQuote } from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
/** Adversarial values a model could pass as pattern / path / include. */
|
||||
const HOSTILE: readonly string[] = [
|
||||
'plain',
|
||||
'with spaces',
|
||||
"it's got 'quotes'",
|
||||
'"double quoted"',
|
||||
'$(rm -rf /tmp/nope)',
|
||||
'`touch /tmp/nope`',
|
||||
'$HOME and ${PATH}',
|
||||
'semi;colon && chain || pipe | bg &',
|
||||
'newline\nin the middle',
|
||||
'-leading-dash',
|
||||
'--leading-double-dash',
|
||||
'*?[a-z]{x,y}',
|
||||
'!bang',
|
||||
'\\backslash\\',
|
||||
'~tilde',
|
||||
'# not a comment',
|
||||
'>redirect <input 2>&1',
|
||||
]
|
||||
|
||||
describe('singleQuote', () => {
|
||||
it('wraps a plain value in single quotes', () => {
|
||||
expect(singleQuote('abc')).toBe("'abc'")
|
||||
})
|
||||
|
||||
it("rewrites embedded single quotes as '\\''", () => {
|
||||
expect(singleQuote("a'b")).toBe("'a'\\''b'")
|
||||
expect(singleQuote("''")).toBe("''\\'''\\'''")
|
||||
})
|
||||
|
||||
it.each(HOSTILE.map(value => [JSON.stringify(value), value] as const))(
|
||||
'round-trips %s through a real bash -c unchanged',
|
||||
(_label, value) => {
|
||||
const result = spawnSync('bash', ['-c', `printf '%s' ${singleQuote(value)}`], { encoding: 'utf8' })
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe(value)
|
||||
},
|
||||
)
|
||||
|
||||
it('a quoted command substitution does not execute (the world stays untouched)', () => {
|
||||
const canary = `/tmp/dsh-quote-canary-${process.pid}`
|
||||
const result = spawnSync('bash', ['-c', `printf '%s' ${singleQuote(`$(touch ${canary})`)}`], { encoding: 'utf8' })
|
||||
expect(result.stdout).toBe(`$(touch ${canary})`)
|
||||
// The canary file must NOT exist — the substitution stayed literal.
|
||||
expect(spawnSync('test', ['-e', canary]).status).not.toBe(0)
|
||||
})
|
||||
})
|
||||
632
packages/fs/tool-fs-search/tests/tools.spec.ts
Normal file
632
packages/fs/tool-fs-search/tests/tools.spec.ts
Normal file
@@ -0,0 +1,632 @@
|
||||
/**
|
||||
* Consumer-surface tests for the search tools over a FAKE bash executor and a
|
||||
* FAKE spill backend, exercised through `ctx.tools.execute()` so nothing
|
||||
* bypasses the tool registry. The fake executor makes every seam outcome
|
||||
* scriptable — truncated stdout with/without a raw spill path, abort/timeout,
|
||||
* signal kills, ripgrep exit codes — so these tests verify schemas, argument
|
||||
* validation, shell-safe command construction, workdir derivation, signal
|
||||
* forwarding, `SEARCH_*` error classification, retention, formatted-result
|
||||
* spill handoff, and the no-background-task invariant. Real-`rg` behavior is
|
||||
* pinned separately in integration.spec.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
import {
|
||||
buildGlobCommand,
|
||||
buildGrepCommand,
|
||||
formatGrepMatches,
|
||||
parseGrepMatches,
|
||||
presentGlobCall,
|
||||
presentGrepCall,
|
||||
previewLine,
|
||||
toWorkdirRelative,
|
||||
} from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
/** A successful run result over the given stdout; overrides script the failure shapes. */
|
||||
function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunResult {
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: 60_000,
|
||||
stdout: { text: stdout, truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A scriptable fake executor: `resolve()` mirrors the real request→spec
|
||||
* defaulting (workdir falls back to `/work`), `run()` returns whatever the
|
||||
* test armed via `handler`, and `start()` throws — the search tools must NEVER
|
||||
* create a background task.
|
||||
*/
|
||||
class FakeBash extends BashExecutor {
|
||||
requests: BashExecRequest[] = []
|
||||
specs: BashExecSpec[] = []
|
||||
startCalls = 0
|
||||
handler: (spec: BashExecSpec) => BashRunResult = () => runResult('')
|
||||
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
this.requests.push(request)
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? '/work',
|
||||
timeoutMs: request.timeoutMs ?? 60_000,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
signal: request.signal,
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
}
|
||||
override run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
this.specs.push(spec)
|
||||
return Promise.resolve(this.handler(spec))
|
||||
}
|
||||
override start(): BashProcess {
|
||||
this.startCalls++
|
||||
throw new Error('search tools must never start a background task')
|
||||
}
|
||||
}
|
||||
|
||||
/** A recording spill backend; arm `failWith` to script a storage failure. */
|
||||
class FakeSpill extends SpillStore {
|
||||
saves: SaveTextSpill[] = []
|
||||
failWith?: Error
|
||||
|
||||
override saveText(input: SaveTextSpill): Promise<SpillRef> {
|
||||
if (this.failWith) return Promise.reject(this.failWith)
|
||||
this.saves.push(input)
|
||||
return Promise.resolve({
|
||||
locator: SpillLocator(`/spill/${input.suggestedName}`),
|
||||
bytes: Buffer.byteLength(input.content, 'utf8'),
|
||||
retrievalHint: 'Use the fake retrieval hint.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
interface SetupOptions {
|
||||
config?: ToolFsSearch.Config
|
||||
spill?: boolean
|
||||
}
|
||||
|
||||
async function setup(options: SetupOptions = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeBash)
|
||||
if (options.spill === true) await ctx.plugin(FakeSpill)
|
||||
const fiber = await ctx.plugin(ToolFsSearch, options.config)
|
||||
const bash = ctx.bash as FakeBash
|
||||
const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined
|
||||
return { ctx, bash, spill, fiber }
|
||||
}
|
||||
|
||||
/** A stand-in agent whose session header carries the given cwd (and a stable id). */
|
||||
const agent = (cwd?: string) => ({ session: { header: { id: 'session-1', ...cwd !== undefined ? { cwd } : {} } } })
|
||||
|
||||
let callCounter = 0
|
||||
function call(ctx: Context, name: string, args: unknown, options: { agent?: object; signal?: AbortSignal } = {}) {
|
||||
return ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...options.agent ? { agent: options.agent as never } : {},
|
||||
...options.signal ? { signal: options.signal } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
/** One rg --json match record line. */
|
||||
function matchLine(path: string, lineNumber: number, lineText: string): string {
|
||||
return JSON.stringify({ type: 'match', data: { path: { text: path }, lines: { text: lineText }, line_number: lineNumber, absolute_offset: 0, submatches: [] } })
|
||||
}
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers glob and grep with their prompt sections', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['glob', 'grep'])
|
||||
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
|
||||
expect(prompt).toContain('Use the glob tool')
|
||||
expect(prompt).toContain('Use the grep tool')
|
||||
})
|
||||
|
||||
it('stays pending until ctx.bash exists (inject)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(ToolFsSearch) // no bash executor
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('unregisters everything on fiber disposal (HMR safety)', async () => {
|
||||
const { ctx, fiber } = await setup()
|
||||
expect(ctx.tools.schemas()).toHaveLength(2)
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
const sections = (await ctx.systemPrompt.assemble()).sections.map(s => s.name)
|
||||
expect(sections).not.toContain('tool:glob')
|
||||
expect(sections).not.toContain('tool:grep')
|
||||
})
|
||||
|
||||
it('attaches the configured timeoutMs to both tool definitions', async () => {
|
||||
const { ctx } = await setup({ config: { timeoutMs: 5000 } })
|
||||
expect(ctx.tools.get('glob')?.timeoutMs).toBe(5000)
|
||||
expect(ctx.tools.get('grep')?.timeoutMs).toBe(5000)
|
||||
})
|
||||
|
||||
it('defaults the timeout budget to 30 seconds', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(ctx.tools.get('glob')?.timeoutMs).toBe(30_000)
|
||||
expect(ctx.tools.get('grep')?.timeoutMs).toBe(30_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('config validation', () => {
|
||||
it.each([
|
||||
['globMaxResults', { globMaxResults: 0 }],
|
||||
['grepMaxMatches', { grepMaxMatches: -1 }],
|
||||
['grepMaxLineBytes', { grepMaxLineBytes: 1.5 }],
|
||||
['rawOutputMaxBytes', { rawOutputMaxBytes: 0 }],
|
||||
['timeoutMs', { timeoutMs: -100 }],
|
||||
] as const)('rejects a non-positive or fractional %s at load', async (name, config) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeBash)
|
||||
await expect(ctx.plugin(ToolFsSearch, config)).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`))
|
||||
})
|
||||
})
|
||||
|
||||
describe('command construction (shell-safe)', () => {
|
||||
it('glob: fixed rg --files template with quoted pattern and paired VCS excludes', () => {
|
||||
const command = buildGlobCommand({ pattern: '**/*.ts' })
|
||||
expect(command).toBe(
|
||||
"rg --files --glob='**/*.ts' --sort=modified --no-ignore --hidden "
|
||||
+ "--glob='!**/.git' --glob='!**/.git/**' --glob='!**/.svn' --glob='!**/.svn/**' "
|
||||
+ "--glob='!**/.hg' --glob='!**/.hg/**' --glob='!**/.bzr' --glob='!**/.bzr/**' "
|
||||
+ "--glob='!**/.jj' --glob='!**/.jj/**' --glob='!**/.sl' --glob='!**/.sl/**'",
|
||||
)
|
||||
})
|
||||
|
||||
it('glob: the search root rides behind -- and is quoted', () => {
|
||||
const command = buildGlobCommand({ pattern: '*.md', path: 'docs dir' })
|
||||
expect(command).toContain("-- 'docs dir'")
|
||||
})
|
||||
|
||||
it('grep: fixed rg --json template with the pattern in --regexp= form', () => {
|
||||
expect(buildGrepCommand({ pattern: 'foo.*bar' })).toBe("rg --json --regexp='foo.*bar'")
|
||||
})
|
||||
|
||||
it('grep: include and path are quoted, include in --glob= form, path behind --', () => {
|
||||
const command = buildGrepCommand({ pattern: 'x', path: '-leading-dash', include: '*.{ts,tsx}' })
|
||||
expect(command).toBe("rg --json --regexp='x' --glob='*.{ts,tsx}' -- '-leading-dash'")
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a command-substitution pattern', '$(rm -rf /)', "'$(rm -rf /)'"],
|
||||
['a backtick pattern', '`touch pwned`', "'`touch pwned`'"],
|
||||
['a pattern with double quotes and spaces', 'say "hi there"', '\'say "hi there"\''],
|
||||
['a pattern with single quotes', "it's", '\'it\'\\\'\'s\''],
|
||||
['a pattern with newlines', 'a\nb', "'a\nb'"],
|
||||
['a leading-dash pattern', '--flag', "'--flag'"],
|
||||
['glob metacharacters', '*?[a-z]{x,y}', "'*?[a-z]{x,y}'"],
|
||||
])('quotes %s into one inert shell word', (_label, raw, quoted) => {
|
||||
expect(buildGrepCommand({ pattern: raw })).toBe(`rg --json --regexp=${quoted}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workdir derivation and signal forwarding', () => {
|
||||
it('forwards the session cwd as the request workdir', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('a.ts\n')
|
||||
await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') })
|
||||
expect(bash.requests[0]?.workdir).toBe('/sessions/s1')
|
||||
expect(bash.specs[0]?.workdir).toBe('/sessions/s1')
|
||||
})
|
||||
|
||||
it('omits the request workdir without a session cwd so resolve() defaults apply', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('a.ts\n')
|
||||
await call(ctx, 'glob', { pattern: '*' }, { agent: agent() })
|
||||
expect(bash.requests[0]).not.toHaveProperty('workdir')
|
||||
expect(bash.specs[0]?.workdir).toBe('/work')
|
||||
// A non-agent caller takes the same default path.
|
||||
await call(ctx, 'grep', { pattern: 'x' })
|
||||
expect(bash.requests[1]).not.toHaveProperty('workdir')
|
||||
})
|
||||
|
||||
it('forwards exec.signal into the bash spec (the abort reaches the backend)', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
bash.handler = spec => runResult('', { aborted: spec.signal?.aborted === true })
|
||||
const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal })
|
||||
expect(bash.specs[0]?.signal).toBe(controller.signal)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
|
||||
expect(text(result)).toContain('aborted')
|
||||
})
|
||||
|
||||
it('reports the bash executor timeout as SEARCH_ABORTED with the budget', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { timedOut: true, timeoutMs: 1234, exitCode: null, signal: 'SIGTERM' })
|
||||
const result = await call(ctx, 'glob', { pattern: '*' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_ABORTED' })
|
||||
expect(text(result)).toContain('timed out after 1234ms')
|
||||
})
|
||||
|
||||
it('translates a run() rejection under a pre-aborted signal into SEARCH_ABORTED', async () => {
|
||||
// The seam contract: run() REJECTS for a pre-aborted signal (it never
|
||||
// spawns). The plain rejection must not escape the SEARCH_* taxonomy.
|
||||
const { ctx, bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
bash.handler = () => { throw new Error('aborted before spawn') }
|
||||
const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
|
||||
})
|
||||
|
||||
it('translates a run() rejection without an abort (unusable workdir) into SEARCH_FAILED', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => { throw new Error('spawn bash ENOENT') }
|
||||
const result = await call(ctx, 'glob', { pattern: '*' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
|
||||
expect(text(result)).toContain('could not start')
|
||||
})
|
||||
})
|
||||
|
||||
describe('exit semantics and failure classification', () => {
|
||||
it('exit 1 is a successful empty search', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: 1 })
|
||||
const glob = await call(ctx, 'glob', { pattern: '*.nope' })
|
||||
expect(glob.isError).toBe(false)
|
||||
expect(text(glob)).toBe('No files found')
|
||||
const grep = await call(ctx, 'grep', { pattern: 'nope' })
|
||||
expect(grep.isError).toBe(false)
|
||||
expect(text(grep)).toBe('No matches found')
|
||||
})
|
||||
|
||||
it('a regex parse error classifies as SEARCH_INVALID_PATTERN', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: regex parse error:\n (\nerror: unclosed group', truncated: false } })
|
||||
const result = await call(ctx, 'grep', { pattern: '(' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' })
|
||||
expect(text(result)).toContain('regex parse error')
|
||||
})
|
||||
|
||||
it('a glob parse error classifies as SEARCH_INVALID_PATTERN', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: error parsing glob \'[\': unclosed character class', truncated: false } })
|
||||
const result = await call(ctx, 'glob', { pattern: '[' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' })
|
||||
})
|
||||
|
||||
it('a missing rg binary classifies as SEARCH_FAILED naming ripgrep', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: 127, stderr: { text: 'bash: line 1: rg: command not found', truncated: false } })
|
||||
const result = await call(ctx, 'glob', { pattern: '*' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
|
||||
expect(text(result)).toContain('requires ripgrep (rg)')
|
||||
// The same classification holds from either evidence alone: the 127 exit
|
||||
// with silent stderr, or a shell's command-not-found text on another exit.
|
||||
bash.handler = () => runResult('', { exitCode: 127 })
|
||||
expect(text(await call(ctx, 'glob', { pattern: '*' }))).toContain('requires ripgrep (rg)')
|
||||
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'sh: rg: command not found', truncated: false } })
|
||||
expect(text(await call(ctx, 'grep', { pattern: 'x' }))).toContain('requires ripgrep (rg)')
|
||||
})
|
||||
|
||||
it('other nonzero exits are SEARCH_FAILED carrying the stderr excerpt', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: missing.dir: IO error: no such file or directory', truncated: false } })
|
||||
const result = await call(ctx, 'grep', { pattern: 'x', path: 'missing.dir' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
|
||||
expect(text(result)).toContain('IO error')
|
||||
})
|
||||
|
||||
it('a nonzero exit with EMPTY stderr still reports the exit code', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: 3 })
|
||||
const result = await call(ctx, 'glob', { pattern: '*' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
|
||||
expect(text(result)).toContain('exit 3')
|
||||
})
|
||||
|
||||
it('truncated stderr gains a truncation note and stderr.spillPath is never read', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', {
|
||||
exitCode: 2,
|
||||
stderr: { text: 'tail of diagnostics', truncated: true, spillPath: '/does/not/exist-and-never-read' },
|
||||
})
|
||||
const result = await call(ctx, 'grep', { pattern: 'x' })
|
||||
expect(text(result)).toContain('tail of diagnostics [stderr truncated]')
|
||||
})
|
||||
|
||||
it('a signal kill (not timeout, not abort) is SEARCH_FAILED', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: null, signal: 'SIGKILL' })
|
||||
const result = await call(ctx, 'grep', { pattern: 'x' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
|
||||
expect(text(result)).toContain('SIGKILL')
|
||||
})
|
||||
|
||||
it('a null exit with no signal (defensive) is SEARCH_FAILED', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: null, signal: null })
|
||||
const result = await call(ctx, 'glob', { pattern: '*' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('raw output acquisition', () => {
|
||||
it('passes rawOutputMaxBytes to bash as the stdout capture budget', async () => {
|
||||
const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 1234 } })
|
||||
bash.handler = () => runResult('', { exitCode: 1 })
|
||||
await call(ctx, 'glob', { pattern: '*.ts' })
|
||||
await call(ctx, 'grep', { pattern: 'needle' })
|
||||
expect(bash.requests.map(request => request.stdoutMaxBytes)).toEqual([1234, 1234])
|
||||
expect(bash.specs.map(spec => spec.stdoutMaxBytes)).toEqual([1234, 1234])
|
||||
})
|
||||
|
||||
it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when truncated stdout has a raw spill path', async () => {
|
||||
const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } })
|
||||
bash.handler = () => runResult('', { stdout: { text: 'x', truncated: true, spillPath: '/does/not/get-read' } })
|
||||
const result = await call(ctx, 'glob', { pattern: '*' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
|
||||
expect(text(result)).toContain('narrow pattern, path, or include')
|
||||
})
|
||||
|
||||
it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when UNTRUNCATED inline stdout exceeds the cap', async () => {
|
||||
// An executor retaining more inline than this package's cap (or a
|
||||
// deployment lowering rawOutputMaxBytes below the bash retention) must not
|
||||
// smuggle an over-cap parse through the untruncated path.
|
||||
const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } })
|
||||
bash.handler = () => runResult(`${'x'.repeat(64)}\n`)
|
||||
const result = await call(ctx, 'grep', { pattern: 'x' })
|
||||
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
|
||||
expect(text(result)).toContain('narrow pattern, path, or include')
|
||||
})
|
||||
|
||||
it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when truncated stdout has no spill path', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { stdout: { text: 'partial', truncated: true } })
|
||||
const result = await call(ctx, 'grep', { pattern: 'x' })
|
||||
expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('glob results', () => {
|
||||
it('lists workdir-relative paths (absolute output under the workdir is relativized)', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n')
|
||||
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') })
|
||||
expect(text(result)).toBe('src/a.ts\n/elsewhere/b.ts\nrel/c.ts')
|
||||
})
|
||||
|
||||
it('validates arguments (blank pattern, blank path)', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(text(await call(ctx, 'glob', { pattern: ' ' }))).toContain('pattern must be a non-empty string')
|
||||
expect(text(await call(ctx, 'glob', { pattern: '*', path: ' ' }))).toContain('path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('threads a valid path through to the command as the quoted search root', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('sub/a.ts\n')
|
||||
const result = await call(ctx, 'glob', { pattern: '*.ts', path: 'sub' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(bash.specs[0]?.command).toContain("-- 'sub'")
|
||||
})
|
||||
|
||||
it('caps at globMaxResults and saves the FULL sorted list through spillStore', async () => {
|
||||
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true })
|
||||
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n')
|
||||
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result stored at: /spill/glob-results.txt. Use the fake retrieval hint.)')
|
||||
expect(spill?.saves).toHaveLength(1)
|
||||
expect(spill?.saves[0]).toMatchObject({
|
||||
owner: { sessionId: 'session-1' },
|
||||
source: { toolName: 'glob', label: 'result' },
|
||||
suggestedName: 'glob-results.txt',
|
||||
content: 'a.ts\nb.ts\nc.ts\nd.ts',
|
||||
})
|
||||
expect(spill?.saves[0]?.source.callId).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not create a spill file when the result fits inline', async () => {
|
||||
const { ctx, bash, spill } = await setup({ spill: true })
|
||||
bash.handler = () => runResult('a.ts\nb.ts\n')
|
||||
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })
|
||||
expect(text(result)).toBe('a.ts\nb.ts')
|
||||
expect(spill?.saves).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['no spill backend loaded', { fail: false, spill: false, ownerless: false }],
|
||||
['saveText fails', { fail: true, spill: true, ownerless: false }],
|
||||
['no session owner', { fail: false, spill: true, ownerless: true }],
|
||||
])('keeps the inline page and reports the unsaved remainder when %s', async (_label, mode) => {
|
||||
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: mode.spill })
|
||||
if (mode.fail && spill) spill.failWith = new Error('disk full')
|
||||
bash.handler = () => runResult('a.ts\nb.ts\n')
|
||||
const result = await call(ctx, 'glob', { pattern: '*' }, mode.ownerless ? {} : { agent: agent('/w') })
|
||||
expect(result.isError).toBe(false) // spill unavailability never fails the search
|
||||
expect(text(result)).toBe('a.ts\n\n(Showing 1 of 2 paths. The complete result could not be saved; narrow pattern or path to see more.)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('grep results', () => {
|
||||
it('groups matches by file with line numbers', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult([
|
||||
JSON.stringify({ type: 'begin', data: { path: { text: 'a.ts' } } }),
|
||||
matchLine('a.ts', 3, 'const x = 1\n'),
|
||||
matchLine('a.ts', 9, 'const y = 2\n'),
|
||||
JSON.stringify({ type: 'end', data: { path: { text: 'a.ts' } } }),
|
||||
matchLine('b.ts', 1, 'const z = 3'),
|
||||
JSON.stringify({ type: 'summary', data: {} }),
|
||||
'',
|
||||
].join('\n'))
|
||||
const result = await call(ctx, 'grep', { pattern: 'const' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe('Found 3 matches\n\na.ts\nLine 3: const x = 1\nLine 9: const y = 2\n\nb.ts\nLine 1: const z = 3')
|
||||
})
|
||||
|
||||
it('reports a single match in the singular', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult(`${matchLine('a.ts', 1, 'hit')}\n`)
|
||||
expect(text(await call(ctx, 'grep', { pattern: 'hit' }))).toBe('Found 1 match\n\na.ts\nLine 1: hit')
|
||||
})
|
||||
|
||||
it('relativizes absolute match paths against the resolved workdir', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult(`${matchLine('/sessions/s1/deep/a.ts', 2, 'hit')}\n`)
|
||||
const result = await call(ctx, 'grep', { pattern: 'hit', path: '/sessions/s1' }, { agent: agent('/sessions/s1') })
|
||||
expect(text(result)).toContain('deep/a.ts\nLine 2: hit')
|
||||
})
|
||||
|
||||
it('previews a long matched line at grepMaxLineBytes preserving UTF-8', async () => {
|
||||
const { ctx, bash } = await setup({ config: { grepMaxLineBytes: 7 } })
|
||||
// 'héllo wörld' cut at 7 bytes lands mid-'é'? h(1)é(2)l(1)l(1)o(1)=6, space=7 → clean cut at 7.
|
||||
// Use a multibyte straddle instead: 'aé' repeated — cut at 7 bytes: a(1)é(2)a(1)é(2)=6 +a(1)=7 → next é straddles: trimmed.
|
||||
bash.handler = () => runResult(`${matchLine('a.txt', 1, 'aéaéaéaé')}\n`)
|
||||
const result = await call(ctx, 'grep', { pattern: 'a' })
|
||||
expect(text(result)).toContain('Line 1: aéaéa (line truncated)')
|
||||
})
|
||||
|
||||
it('renders a non-UTF-8 line (rg bytes form) as a placeholder instead of failing', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
const record = JSON.stringify({ type: 'match', data: { path: { text: 'bin.dat' }, lines: { bytes: 'AAECww==' }, line_number: 4 } })
|
||||
bash.handler = () => runResult(`${record}\n`)
|
||||
expect(text(await call(ctx, 'grep', { pattern: 'x' }))).toContain('Line 4: (line is not valid UTF-8)')
|
||||
})
|
||||
|
||||
it('strips a CRLF terminator from the matched line text', () => {
|
||||
const matches = parseGrepMatches(`${matchLine('a.txt', 1, 'windows line\r\n')}\n`)
|
||||
expect(matches[0]?.line).toBe('windows line')
|
||||
})
|
||||
|
||||
it('caps at grepMaxMatches and spills the full formatted match list', async () => {
|
||||
const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 2 }, spill: true })
|
||||
bash.handler = () => runResult([
|
||||
matchLine('a.ts', 1, 'one'),
|
||||
matchLine('a.ts', 2, 'two'),
|
||||
matchLine('b.ts', 3, 'three'),
|
||||
'',
|
||||
].join('\n'))
|
||||
const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') })
|
||||
expect(text(result)).toBe('Found 2 of 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\n(Full grep result stored at: /spill/grep-results.txt. Use the fake retrieval hint.)')
|
||||
expect(spill?.saves[0]).toMatchObject({
|
||||
source: { toolName: 'grep', label: 'result' },
|
||||
suggestedName: 'grep-results.txt',
|
||||
content: 'Found 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\nb.ts\nLine 3: three',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the unsaved remainder when capped with no spill backend', async () => {
|
||||
const { ctx, bash } = await setup({ config: { grepMaxMatches: 1 } })
|
||||
bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('a.ts', 2, 'two')}\n`)
|
||||
const result = await call(ctx, 'grep', { pattern: 'o' }, { agent: agent('/w') })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe('Found 1 of 2 matches\n\na.ts\nLine 1: one\n\n(The complete result could not be saved; narrow pattern, path, or include to see more.)')
|
||||
})
|
||||
|
||||
it('validates arguments (empty pattern, blank path, bad include)', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(text(await call(ctx, 'grep', { pattern: '' }))).toContain('pattern must be a non-empty string')
|
||||
expect(text(await call(ctx, 'grep', { pattern: 'x', path: ' ' }))).toContain('path must be a non-empty string')
|
||||
expect(text(await call(ctx, 'grep', { pattern: 'x', include: ' ' }))).toContain('include must be a non-empty glob')
|
||||
expect(text(await call(ctx, 'grep', { pattern: 'x', include: '!*.ts' }))).toContain('negated patterns')
|
||||
expect(text(await call(ctx, 'grep', { pattern: 'x', include: '*.ts,*.js' }))).toContain('comma-separated list')
|
||||
})
|
||||
|
||||
it('accepts a whitespace-only pattern (a legitimate regex) and brace alternation in include', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { exitCode: 1 })
|
||||
const result = await call(ctx, 'grep', { pattern: ' ', include: '*.{ts,tsx}' })
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rg --json transport failures (SEARCH_FAILED)', () => {
|
||||
it.each([
|
||||
['a non-JSON line', 'not json at all'],
|
||||
['a non-object record', '42'],
|
||||
['a match record with no data', JSON.stringify({ type: 'match' })],
|
||||
['a match record with no path text', JSON.stringify({ type: 'match', data: { path: {}, lines: { text: 'x' }, line_number: 1 } })],
|
||||
['a match record with a non-object path', JSON.stringify({ type: 'match', data: { path: 'a.ts', lines: { text: 'x' }, line_number: 1 } })],
|
||||
['a match record with no line number', JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, lines: { text: 'x' } } })],
|
||||
['a match record with no line content', JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, line_number: 1 } })],
|
||||
['a match record with neither text nor bytes', JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, lines: {}, line_number: 1 } })],
|
||||
])('%s fails the search', async (_label, line) => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult(`${line}\n`)
|
||||
const result = await call(ctx, 'grep', { pattern: 'x' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('the no-background-task invariant', () => {
|
||||
it('never calls ctx.bash.start() across successful and failed searches', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('a.ts\n')
|
||||
await call(ctx, 'glob', { pattern: '*' })
|
||||
bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'boom', truncated: false } })
|
||||
await call(ctx, 'grep', { pattern: 'x' })
|
||||
expect(bash.startCalls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('presentation', () => {
|
||||
it('glob titles carry the pattern and optional root', () => {
|
||||
expect(presentGlobCall({ pattern: '**/*.ts' })).toMatchObject({ card: 'generic', title: 'Glob **/*.ts', kind: 'search' })
|
||||
expect(presentGlobCall({ pattern: '*.md', path: 'docs' }).title).toBe('Glob *.md in docs')
|
||||
})
|
||||
|
||||
it('grep titles carry the pattern, target, and include filter', () => {
|
||||
expect(presentGrepCall({ pattern: 'todo' })).toMatchObject({ card: 'generic', title: 'Grep todo', kind: 'search' })
|
||||
expect(presentGrepCall({ pattern: 'todo', path: 'src', include: '*.ts' }).title).toBe('Grep todo in src (*.ts)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('helpers', () => {
|
||||
it('toWorkdirRelative maps inside-workdir absolutes and passes everything else through', () => {
|
||||
expect(toWorkdirRelative('/w/a/b.ts', '/w')).toBe('a/b.ts')
|
||||
expect(toWorkdirRelative('/w', '/w')).toBe('.')
|
||||
expect(toWorkdirRelative('/other/b.ts', '/w')).toBe('/other/b.ts')
|
||||
expect(toWorkdirRelative('/w-sibling/b.ts', '/w')).toBe('/w-sibling/b.ts')
|
||||
expect(toWorkdirRelative('rel/b.ts', '/w')).toBe('rel/b.ts')
|
||||
// Normalization makes this land OUTSIDE the workdir → original path kept.
|
||||
expect(toWorkdirRelative('/w/../up.ts', '/w')).toBe('/w/../up.ts')
|
||||
})
|
||||
|
||||
it('previewLine keeps a within-budget line untouched', () => {
|
||||
expect(previewLine('short', 100)).toBe('short')
|
||||
})
|
||||
|
||||
it('formatGrepMatches groups by first-seen file order', () => {
|
||||
const grouped = formatGrepMatches([
|
||||
{ path: 'b.ts', lineNumber: 2, line: 'x' },
|
||||
{ path: 'a.ts', lineNumber: 1, line: 'y' },
|
||||
{ path: 'b.ts', lineNumber: 5, line: 'z' },
|
||||
])
|
||||
expect(grouped).toBe('b.ts\nLine 2: x\nLine 5: z\n\na.ts\nLine 1: y')
|
||||
})
|
||||
})
|
||||
20
packages/fs/tool-fs-search/tsconfig.json
Normal file
20
packages/fs/tool-fs-search/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../util/retention" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
{ "path": "../../bash/bash" },
|
||||
{ "path": "../../spill/spill" }
|
||||
]
|
||||
}
|
||||
@@ -100,6 +100,6 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No directory-listing, glob, grep, or search tools ship** — a deferral of [the tool-schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md); `ctx.fs.listDir` serves provider code such as skill discovery but still has no model-facing consumer, so models fall back to `bash`.
|
||||
- **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies bash-backed `glob` and `grep` rather than extending the filesystem seam.
|
||||
- **`read` handles UTF-8 text files only** — binary-safe reads and PDF/image/multimodal content are deferred; a directory target is `FS_NOT_REGULAR_FILE`.
|
||||
- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only (the deliberate [fs-family stance](../README.md)).
|
||||
|
||||
@@ -22,6 +22,7 @@ function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? '/stub',
|
||||
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 } : {},
|
||||
|
||||
13
packages/spill/README.md
Normal file
13
packages/spill/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# spill/ - spill storage capability family
|
||||
|
||||
The tool-output spill capability seam: an abstract storage interface, a local filesystem implementation, and the tool-result policy that uses it. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `spill/` | Abstract spill storage seam (`saveText` — persist oversized tool text and return a locator + retrieval hint) | `ctx.spillStore` |
|
||||
| `spill-local/` | Local-filesystem backend: private, session-scoped files with traversal-safe names | (registers on `ctx.spillStore`) |
|
||||
| `spill-policy/` | `tools/post-execute` policy: replaces oversized plain-text results with a preview + spill locator | (no service surface) |
|
||||
|
||||
The interface lives at `spill/spill/`. The split mirrors bash/fs: the seam owns storage only, `spill-local` owns the filesystem mechanics, and `spill-policy` owns WHEN to spill and the model-facing notice. Preview mechanics stay in [`util/retention`](../util/README.md) — the policy composes the two without either owning the other's job.
|
||||
|
||||
See the [tool output spill RFC](../../docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design rationale, including why final-result spill is separate from tool-owned early spill (bash streams, subagent rollouts) and why creation belongs to the runtime spill seam rather than the model-facing `write` tool.
|
||||
28
packages/spill/spill-local/README.md
Normal file
28
packages/spill/spill-local/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# @deepseek-ai/dsh-spill-local
|
||||
|
||||
The **local-filesystem** implementation of the [`@deepseek-ai/dsh-spill`](../spill) storage seam. Registers as `ctx.spillStore` and persists a tool's oversized text to a private, session-scoped file; its locator is the file path and its retrieval hint tells the model to use `read` or `grep` on that path.
|
||||
|
||||
## Storage layout
|
||||
|
||||
Files land at `<root>/session-<hash>/<random>-<safeName>`:
|
||||
|
||||
- **`root`** — the config `root` (resolved to absolute), or a lazily-created private (0700) per-process directory under the OS temp dir when omitted. A predictable, world-readable root would let other local users read spilled tool output or plant symlinks.
|
||||
- **`session-<hash>`** — a short `sha256(sessionId)` prefix, so a session's spill files group together and a future cleanup can drop them per session.
|
||||
- **`<random>-<safeName>`** — an unpredictable hex prefix (defeats symlink planting in a shared root) plus the caller's `suggestedName` sanitized to one safe path segment (traversal-proof; mirrors the JSONL persistence backend's `encodeSegment`). The write is exclusive + owner-only (`open(path, 'wx', 0o600)`): it fails on any pre-existing path, symlink or not, so a planted target cannot redirect it.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `root` | private 0700 temp dir | Root directory for spill files. Set to keep them under a known location. |
|
||||
|
||||
`saveText` rejects on a real storage failure (permissions, ENOSPC); the spill policy treats a rejection as best-effort and keeps the inline result. See the seam README for the vocabulary and the [tool output spill RFC](../../../docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through spill consumers that render the local path and `read`/`grep` retrieval guidance.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Local spill files persist until external cleanup** — the backend has no session-lifecycle deletion or age-based retention policy, because persisted, resumed, and forked sessions may still reference a path.
|
||||
- **Locators require a co-located filesystem consumer** — a remote or virtual deployment needs another `SpillStore` backend whose locator and retrieval hint are meaningful there.
|
||||
38
packages/spill/spill-local/package.json
Normal file
38
packages/spill/spill-local/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-spill-local",
|
||||
"description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-spill": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
65
packages/spill/spill-local/src/index.ts
Normal file
65
packages/spill/spill-local/src/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* `LocalSpillStore`: the host-filesystem implementation of the
|
||||
* `@deepseek-ai/dsh-spill` storage seam. Persists a tool's oversized text to a
|
||||
* private, session-scoped file (see `./store.ts` for the traversal-safe naming
|
||||
* and exclusive owner-only write) and returns a path locator plus local
|
||||
* read/grep retrieval guidance.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-spill-local
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { resolve } from 'node:path'
|
||||
import z from 'schemastery'
|
||||
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import { privateRoot, saveTextFile } from './store.ts'
|
||||
|
||||
export { encodeSegment, privateRoot, saveTextFile, sessionDir } from './store.ts'
|
||||
export type { SavedText, SaveTextOptions } from './store.ts'
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-filesystem spill backend. Files land under `<root>/session-<hash>/…`
|
||||
* with unpredictable names, an exclusive owner-only (0600) write, and a private
|
||||
* (0700) root — a spilled tool result must not be readable by other local users
|
||||
* or redirectable via a planted symlink.
|
||||
*/
|
||||
export class LocalSpillStore extends SpillStore {
|
||||
static Config: z<Config> = z.object({
|
||||
root: z.string(),
|
||||
})
|
||||
|
||||
/** Resolved absolute spill root (config `root`, else the private default), fixed at construction. */
|
||||
readonly root: string
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
this.root = config.root !== undefined ? resolve(config.root) : privateRoot()
|
||||
}
|
||||
|
||||
async saveText(input: SaveTextSpill): Promise<SpillRef> {
|
||||
const saved = await saveTextFile({
|
||||
root: this.root,
|
||||
sessionId: input.owner.sessionId,
|
||||
suggestedName: input.suggestedName,
|
||||
content: input.content,
|
||||
})
|
||||
return {
|
||||
locator: SpillLocator(saved.path),
|
||||
bytes: saved.bytes,
|
||||
retrievalHint: 'Use read with offset/limit, or grep this path to search within it.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalSpillStore
|
||||
120
packages/spill/spill-local/src/store.ts
Normal file
120
packages/spill/spill-local/src/store.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Cordis-free storage mechanics for the local spill backend: private
|
||||
* session-scoped directory selection, safe-name derivation, path-traversal
|
||||
* protection, and the exclusive owner-only write. Kept out of the service class
|
||||
* (like `dsh-bash-local`'s `run.ts`) so the filesystem behavior is unit-testable
|
||||
* without a `ctx` and without the OS temp dir.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-spill-local/store
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { mkdir, open } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
let defaultRoot: string | undefined
|
||||
|
||||
/**
|
||||
* The default spill root: a private (0700) per-process directory under the OS
|
||||
* tmpdir, created lazily. Predictable world-readable paths would let other
|
||||
* local users read spilled tool output or pre-create symlinks; `mkdtemp` gives
|
||||
* an unpredictable suffix and 0700 semantics.
|
||||
*
|
||||
* @returns The lazily-created private spill root.
|
||||
*/
|
||||
export function privateRoot(): string {
|
||||
defaultRoot ??= mkdtempSync(join(tmpdir(), 'dsh-spill-'))
|
||||
return defaultRoot
|
||||
}
|
||||
|
||||
// Deliberately mirrors the JSONL path encoder, but keeps spill's empty-name
|
||||
// policy (`""` -> `"~"`) local so storage backends stay decoupled.
|
||||
/* jscpd:ignore-start */
|
||||
/**
|
||||
* Encode an arbitrary string as one safe path segment, injectively over ALL JS
|
||||
* (UTF-16) strings. A session id / suggested name is untrusted input, so this
|
||||
* neutralizes `../`, absolute paths, NUL, and separators before any filesystem
|
||||
* use. Each code unit is kept literal (`[A-Za-z0-9._-]`, minus `~`) or escaped
|
||||
* as `~XXXX`; `~` is itself escaped, so the mapping is reversible and distinct
|
||||
* inputs never collide. The whole-segment tokens `.`/`..` are escaped so they
|
||||
* can never traverse. An empty string encodes to `~` (never an empty segment).
|
||||
* (Mirrors the JSONL persistence backend's `encodeSegment`.)
|
||||
*
|
||||
* @param raw The untrusted string to encode as one safe path segment.
|
||||
* @returns An injective, filesystem-safe single path segment.
|
||||
*/
|
||||
export function encodeSegment(raw: string): string {
|
||||
if (raw.length === 0) return '~'
|
||||
if (raw === '.') return '~002E'
|
||||
if (raw === '..') return '~002E~002E'
|
||||
let out = ''
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const code = raw.charCodeAt(i)
|
||||
const ch = String.fromCharCode(code)
|
||||
if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
|
||||
out += ch
|
||||
} else {
|
||||
out += '~' + code.toString(16).toUpperCase().padStart(4, '0')
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* The session-scoped directory: `<root>/session-<hash(sessionId)>`, a short stable hash.
|
||||
*
|
||||
* @param root The spill root directory.
|
||||
* @param sessionId The owning session id to hash into a stable directory name.
|
||||
* @returns The absolute session-scoped spill directory path.
|
||||
*/
|
||||
export function sessionDir(root: string, sessionId: string): string {
|
||||
const hash = createHash('sha256').update(sessionId).digest('hex').slice(0, 12)
|
||||
return join(root, `session-${hash}`)
|
||||
}
|
||||
|
||||
/** Options for {@link saveTextFile} — the resolved root and the request fields the store needs. */
|
||||
export interface SaveTextOptions {
|
||||
/** The spill root directory (configured or the lazy private default). */
|
||||
root: string
|
||||
/** The owning session id (scopes the directory). */
|
||||
sessionId: string
|
||||
/** Caller-suggested base name; sanitized to one safe segment before use. */
|
||||
suggestedName: string
|
||||
/** The full text to persist. */
|
||||
content: string
|
||||
}
|
||||
|
||||
/** A written spill file. */
|
||||
export interface SavedText {
|
||||
path: string
|
||||
bytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Write `content` to a fresh file under the session-scoped directory and return
|
||||
* its path + byte length. The filename is a random hex prefix plus the
|
||||
* sanitized `suggestedName`, so it is unpredictable (defeats symlink planting in
|
||||
* a shared root) AND stays readable. The open is exclusive + owner-only
|
||||
* (`'wx', 0o600`): it fails on any existing path — symlink or not — so a
|
||||
* pre-planted target cannot redirect the write.
|
||||
*
|
||||
* @param options The resolved root and request fields required to save the file.
|
||||
* @returns The written file path and UTF-8 byte length.
|
||||
*/
|
||||
export async function saveTextFile(options: SaveTextOptions): Promise<SavedText> {
|
||||
const dir = sessionDir(options.root, options.sessionId)
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 })
|
||||
const safeName = encodeSegment(options.suggestedName)
|
||||
const path = join(dir, `${randomBytes(6).toString('hex')}-${safeName}`)
|
||||
const bytes = Buffer.byteLength(options.content, 'utf8')
|
||||
const handle = await open(path, 'wx', 0o600)
|
||||
try {
|
||||
await handle.writeFile(options.content)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
return { path, bytes }
|
||||
}
|
||||
139
packages/spill/spill-local/tests/spill-local.spec.ts
Normal file
139
packages/spill/spill-local/tests/spill-local.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Tests for the LOCAL spill backend: `saveText` writes a session-scoped file and
|
||||
* returns a locator + byte length + retrieval hint, filename sanitization
|
||||
* neutralizes traversal, the configured `root` is honored (and the private
|
||||
* default when omitted), and a storage failure rejects. The Cordis-free
|
||||
* `store.ts` helpers are exercised directly for the naming/encoding edge cases.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, isAbsolute, join } from 'node:path'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SaveTextSpill } from '@deepseek-ai/dsh-spill'
|
||||
import LocalSpillStore, { encodeSegment, privateRoot, saveTextFile, sessionDir } from '@deepseek-ai/dsh-spill-local'
|
||||
|
||||
let root: string
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'dsh-spill-test-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function request(overrides: Partial<SaveTextSpill> = {}): SaveTextSpill {
|
||||
return {
|
||||
owner: { sessionId: SessionId('sess-1') },
|
||||
source: { toolName: 'web_fetch', callId: CallId('call-1'), label: 'result' },
|
||||
suggestedName: 'web_fetch.txt',
|
||||
content: 'the full body',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('encodeSegment', () => {
|
||||
it('keeps the safe set literal', () => {
|
||||
expect(encodeSegment('web_fetch.txt')).toBe('web_fetch.txt')
|
||||
expect(encodeSegment('a-B_9.z')).toBe('a-B_9.z')
|
||||
})
|
||||
|
||||
it('escapes separators and tilde (dots are literal except as whole-segment tokens)', () => {
|
||||
// `.` is in the safe set, so `..` inside a longer string stays literal; the
|
||||
// traversal defense is that separators escape, keeping the result ONE segment.
|
||||
expect(encodeSegment('../etc/passwd')).toBe('..~002Fetc~002Fpasswd')
|
||||
expect(encodeSegment('a/b')).toBe('a~002Fb')
|
||||
expect(encodeSegment('~')).toBe('~007E')
|
||||
})
|
||||
|
||||
it('escapes the whole-segment dot tokens', () => {
|
||||
expect(encodeSegment('.')).toBe('~002E')
|
||||
expect(encodeSegment('..')).toBe('~002E~002E')
|
||||
})
|
||||
|
||||
it('encodes the empty string to a non-empty segment', () => {
|
||||
expect(encodeSegment('')).toBe('~')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionDir', () => {
|
||||
it('is a stable per-session hash under the root', () => {
|
||||
const dir = sessionDir('/spill', 'sess-1')
|
||||
expect(dir).toBe(sessionDir('/spill', 'sess-1'))
|
||||
expect(dir).toMatch(/\/spill\/session-[0-9a-f]{12}$/)
|
||||
expect(sessionDir('/spill', 'sess-2')).not.toBe(dir)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveTextFile', () => {
|
||||
it('writes the content under the session dir and reports bytes', async () => {
|
||||
const saved = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: 'r.txt', content: 'héllo' })
|
||||
expect(readFileSync(saved.path, 'utf8')).toBe('héllo')
|
||||
expect(saved.bytes).toBe(Buffer.byteLength('héllo', 'utf8'))
|
||||
expect(dirname(saved.path)).toBe(sessionDir(root, 'sess-1'))
|
||||
expect(saved.path).toMatch(/\/[0-9a-f]{12}-r\.txt$/)
|
||||
})
|
||||
|
||||
it('sanitizes a traversal-shaped suggested name into one segment', async () => {
|
||||
const saved = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: '../../evil', content: 'x' })
|
||||
// The separators escaped, so the whole name is one leaf under the session dir.
|
||||
expect(dirname(saved.path)).toBe(sessionDir(root, 'sess-1'))
|
||||
expect(saved.path.includes('/..')).toBe(false)
|
||||
})
|
||||
|
||||
it('creates the session dir with owner-only permissions', async () => {
|
||||
const saved = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: 'r.txt', content: 'x' })
|
||||
// 0o700 dir, 0o600 file (masked by umask, but the owner bits must hold).
|
||||
expect(statSync(dirname(saved.path)).mode & 0o700).toBe(0o700)
|
||||
expect(statSync(saved.path).mode & 0o600).toBe(0o600)
|
||||
})
|
||||
|
||||
it('gives distinct paths to two saves of the same name', async () => {
|
||||
const a = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: 'r.txt', content: 'a' })
|
||||
const b = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: 'r.txt', content: 'b' })
|
||||
expect(a.path).not.toBe(b.path)
|
||||
})
|
||||
})
|
||||
|
||||
describe('privateRoot', () => {
|
||||
it('is a stable absolute directory under the temp dir', () => {
|
||||
const first = privateRoot()
|
||||
expect(isAbsolute(first)).toBe(true)
|
||||
expect(privateRoot()).toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalSpillStore service', () => {
|
||||
it('registers as ctx.spillStore and saves under the configured root', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSpillStore, { root })
|
||||
const ref = await ctx.spillStore.saveText(request())
|
||||
expect(dirname(ref.locator)).toBe(sessionDir(root, 'sess-1'))
|
||||
expect(readFileSync(ref.locator, 'utf8')).toBe('the full body')
|
||||
expect(ref.bytes).toBe(Buffer.byteLength('the full body', 'utf8'))
|
||||
expect(ref.retrievalHint).toBe('Use read with offset/limit, or grep this path to search within it.')
|
||||
})
|
||||
|
||||
it('resolves a relative configured root to absolute', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSpillStore, { root: '.' })
|
||||
expect(isAbsolute((ctx.spillStore as LocalSpillStore).root)).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the private root when none is configured', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSpillStore, {})
|
||||
expect((ctx.spillStore as LocalSpillStore).root).toBe(privateRoot())
|
||||
})
|
||||
|
||||
it('rejects when the root is not writable (missing parent, exclusive open)', async () => {
|
||||
const ctx = new Context()
|
||||
// A file (not a dir) as the root makes mkdir under it fail — a real storage error.
|
||||
const filePath = (await saveTextFile({ root, sessionId: 's', suggestedName: 'f', content: 'x' })).path
|
||||
await ctx.plugin(LocalSpillStore, { root: filePath })
|
||||
await expect(ctx.spillStore.saveText(request())).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
14
packages/spill/spill-local/tsconfig.json
Normal file
14
packages/spill/spill-local/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../spill" }
|
||||
]
|
||||
}
|
||||
46
packages/spill/spill-policy/README.md
Normal file
46
packages/spill/spill-policy/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# @deepseek-ai/dsh-spill-policy
|
||||
|
||||
The **tool-result spill policy**: a `tools/post-execute` transformer that keeps oversized plain-text tool results out of the model's context. When a final result exceeds `maxInlineBytes`, it saves the FULL text through [`ctx.spillStore`](../spill) and replaces the model-facing result with a bounded head/tail preview plus the backend's locator and retrieval hint.
|
||||
|
||||
This plugin registers **no service** and owns no storage or preview mechanics: preview is [`@deepseek-ai/dsh-retention`](../../util/retention) (`TextRetainer`), storage is `ctx.spillStore`. It only decides WHEN to spill and composes the notice.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `maxInlineBytes` | *(omitted)* | Model-facing context cap for a plain-text result, in UTF-8 bytes (a non-negative integer; validated at load). **Omitted disables the policy entirely** (the plugin registers nothing). When set, a larger result is spilled and replaced with a preview derived from the same budget (head/tail split). |
|
||||
|
||||
## Behavior
|
||||
|
||||
1. Let the tool run (delegates via `next()`, so it bounds whatever a downstream hook accepted).
|
||||
2. Skip `read` (avoids a `read → spill → read again` loop) and any non-`accept` decision (a `block`'s corrective feedback passes through).
|
||||
3. Flatten the accepted content only when it is **plain text** (all `text` blocks); a result with any non-text block is left untouched.
|
||||
4. If its UTF-8 size is `≤ maxInlineBytes`, leave it unchanged.
|
||||
5. Otherwise save the full text and replace the result with a preview + this notice, sized so the whole replacement (preview + blank line + notice) stays within `maxInlineBytes` — the notice's byte cost is reserved out of the budget, so the preview shrinks to fit and the model-facing result never exceeds the cap:
|
||||
|
||||
```text
|
||||
<retained head/tail preview>
|
||||
|
||||
(Omitted N bytes. Full formatted result stored at: /…/session-…/…-web_fetch.txt. Use read with offset/limit, or grep this path to search within it.)
|
||||
```
|
||||
|
||||
When the notice alone fills the budget (a tiny cap or a long locator) the preview is empty and only the notice is returned. If even that notice-only replacement would exceed `maxInlineBytes`, the policy keeps the inline result — it never emits a replacement over the cap (and a within-cap replacement is always smaller than the original, so this also means spilling never adds bytes).
|
||||
|
||||
**Best-effort:** no session owner, no `ctx.spillStore` backend, or a `saveText` rejection ⇒ the policy logs a warning and returns the original result. A spill failure never turns a successful call into an `isError` or hides the inline result.
|
||||
|
||||
## Scope
|
||||
|
||||
The policy sees only the FINAL formatted tool result — not a tool's internal resource. If a provider already truncated (e.g. `web-fetch-local.maxBodyChars`), the spill artifact holds the full formatted result the tool returned, not the full original source. Provider/resource caps stay mandatory and separate. Tool-owned early spill (bash streams, subagent rollouts) is future work — see the [tool output spill RFC](../../../docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Oversized plain-text result
|
||||
|
||||
**What the model sees**: Results at or below `maxInlineBytes`, `read` results, blocked decisions, and results containing non-text blocks are unchanged. An oversized plain-text result becomes a bounded head/tail preview followed by `(Omitted <bytes> bytes. Full formatted result stored at: <locator>. <retrievalHint>)`; storage or ownership failures leave the original result visible.
|
||||
|
||||
**Token effect**: A successful replacement is at most `maxInlineBytes` UTF-8 bytes and remains in history until compaction; the full spill text is not resent to the model.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only final plain-text results are spillable** — mixed-content results, blocked feedback, and `read` pass through; provider truncation or tool-owned retention that happened earlier cannot be recovered here.
|
||||
- **A notice that cannot fit disables replacement for that call** — a tiny cap or long locator leaves the oversized original inline after the backend has already saved an unreferenced spill.
|
||||
44
packages/spill/spill-policy/package.json
Normal file
44
packages/spill/spill-policy/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-spill-policy",
|
||||
"description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service surface)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-retention": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-spill": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-retention": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
174
packages/spill/spill-policy/src/index.ts
Normal file
174
packages/spill/spill-policy/src/index.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* The spill-policy PLUGIN: a `tools/post-execute` result transformer that keeps
|
||||
* oversized plain-text tool results out of the model's context. When a final
|
||||
* result's UTF-8 size exceeds `maxInlineBytes`, it saves the FULL text to a
|
||||
* session-scoped spill artifact (`ctx.spillStore`) and replaces the
|
||||
* model-facing result with a bounded head/tail preview plus the backend's
|
||||
* locator and retrieval guidance.
|
||||
*
|
||||
* It registers NO service and owns NO storage or preview mechanics: preview is
|
||||
* `@deepseek-ai/dsh-retention` (`TextRetainer`), storage is `ctx.spillStore`.
|
||||
* The policy only decides WHEN to spill and composes the notice.
|
||||
*
|
||||
* ## Deliberately narrow
|
||||
*
|
||||
* - Omitted `maxInlineBytes` ⇒ the plugin registers nothing (a true no-op).
|
||||
* - Plain-text results only: a result carrying any non-text block is left
|
||||
* untouched (the policy knows only the final formatted text, not tool
|
||||
* internals).
|
||||
* - `read` is skipped to avoid a `read → spill → read again` loop.
|
||||
* - Best-effort: no session owner, no `ctx.spillStore` backend, or a save
|
||||
* failure ⇒ log and return the original result. A spill failure must NEVER
|
||||
* turn a successful tool call into an `isError` or hide the inline result.
|
||||
*
|
||||
* It COMPOSES with other post-execute listeners: it delegates via `next()` and
|
||||
* bounds the resulting `accept` content, so a hook that replaced the content
|
||||
* still has its replacement bounded, and a `block` decision passes through
|
||||
* unchanged.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-spill-policy
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { TextRetainer, describeOmitted } from '@deepseek-ai/dsh-retention'
|
||||
import type { Omitted } from '@deepseek-ai/dsh-retention'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import type { SpillPolicyExec } from './types.ts'
|
||||
|
||||
export type { SpillPolicyExec } from './types.ts'
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'spill-policy'
|
||||
|
||||
/** Require the tool registry (its `tools/post-execute` waterfall is the seam we transform). */
|
||||
export const inject = ['tools']
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
maxInlineBytes: z.number(),
|
||||
})
|
||||
|
||||
/** All-text content flattened to one UTF-8 string, or `undefined` if any block is non-text. */
|
||||
function flattenPlainText(content: ContentBlock[]): string | undefined {
|
||||
let text = ''
|
||||
for (const block of content) {
|
||||
if (block.type !== 'text') return undefined
|
||||
text += block.text
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/** The owning session id, or `undefined` for a call with no agent (a direct/test call). */
|
||||
function ownerSessionId(exec: ToolExecution): SessionId | undefined {
|
||||
return (exec as SpillPolicyExec).agent?.session.header.id
|
||||
}
|
||||
|
||||
/** Build the bounded head/tail preview for `text`, splitting `budget` bytes across the two ends. */
|
||||
function preview(text: string, budget: number): { text: string; omitted: Omitted } {
|
||||
const headBytes = Math.ceil(budget / 2)
|
||||
const tailBytes = Math.floor(budget / 2)
|
||||
const retainer = new TextRetainer({ kind: 'headTail', headBytes, tailBytes })
|
||||
retainer.push(text)
|
||||
const kept = retainer.finish()
|
||||
return { text: kept.text, omitted: kept.omittedBytes }
|
||||
}
|
||||
|
||||
/** The spill-notice line for a given omission + saved reference (no preview, no leading blank line). */
|
||||
function spillNotice(omitted: Omitted, ref: SpillRef): string {
|
||||
const omission = describeOmitted(omitted, 'bytes')
|
||||
return `(${omission} Full formatted result stored at: ${ref.locator}. ${ref.retrievalHint})`
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const maxInlineBytes = config.maxInlineBytes
|
||||
// Omitted ⇒ no automatic spill policy: register nothing at all.
|
||||
if (maxInlineBytes === undefined) return
|
||||
// Validate at LOAD, not per call: a negative/fractional cap would reach
|
||||
// TextRetainer's assertBudget and throw, turning every oversized-result call
|
||||
// into an isError. A bad config must fail the deployment, not the tool.
|
||||
if (!Number.isInteger(maxInlineBytes) || maxInlineBytes < 0) {
|
||||
throw new Error(`spill-policy: maxInlineBytes must be a non-negative integer (got ${maxInlineBytes})`)
|
||||
}
|
||||
|
||||
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
|
||||
// Delegate first so a downstream listener (e.g. a hook) settles the result;
|
||||
// we bound whatever it accepted. A block passes through — spill only shapes
|
||||
// accepted plain-text results, never corrective feedback.
|
||||
const decision = await next()
|
||||
// Skip `read` to avoid a read → spill → read again loop.
|
||||
if (decision.kind !== 'accept' || exec.name === 'read') return decision
|
||||
|
||||
const content = decision.content ?? result.content
|
||||
const text = flattenPlainText(content)
|
||||
if (text === undefined) return decision
|
||||
const totalBytes = Buffer.byteLength(text, 'utf8')
|
||||
if (totalBytes <= maxInlineBytes) return decision
|
||||
|
||||
const sessionId = ownerSessionId(exec)
|
||||
if (sessionId === undefined) {
|
||||
ctx.logger.warn(`spill-policy: no session owner for ${exec.name} result; keeping the inline result`)
|
||||
return decision
|
||||
}
|
||||
const spillStore = ctx.get('spillStore')
|
||||
if (!spillStore) {
|
||||
ctx.logger.warn('spill-policy: no ctx.spillStore backend loaded; keeping the inline result')
|
||||
return decision
|
||||
}
|
||||
|
||||
const save: SaveTextSpill = {
|
||||
owner: { sessionId },
|
||||
source: { toolName: exec.name, callId: exec.callId, label: 'result' },
|
||||
suggestedName: `${exec.name}.txt`,
|
||||
content: text,
|
||||
}
|
||||
let ref: SpillRef
|
||||
try {
|
||||
ref = await spillStore.saveText(save)
|
||||
} catch (error: unknown) {
|
||||
// Best-effort: a storage failure (permissions, ENOSPC, backend down) must
|
||||
// never fail the call or hide the result — keep the original inline.
|
||||
ctx.logger.warn(`spill-policy: saveText failed for ${exec.name}: ${String(error)}; keeping the inline result`)
|
||||
return decision
|
||||
}
|
||||
|
||||
// Reserve the notice's byte cost INSIDE maxInlineBytes so the replacement
|
||||
// (preview + blank line + notice) never exceeds the documented cap — a naive
|
||||
// preview that spent the whole budget then appended the notice could be
|
||||
// larger than the cap, and for a marginally-over result even larger than the
|
||||
// original. The reservation uses a notice priced at the worst-case omission
|
||||
// count (the full byte total): its digit count bounds the real count's, so
|
||||
// the reserved size is a safe upper bound and the final notice is never
|
||||
// longer than what we reserved. `\n\n` is the 2-byte join.
|
||||
const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2
|
||||
const previewBudget = Math.max(0, maxInlineBytes - reserve)
|
||||
const { text: previewText, omitted } = preview(text, previewBudget)
|
||||
const notice = spillNotice(omitted, ref)
|
||||
const replacedText = previewText.length > 0 ? `${previewText}\n\n${notice}` : notice
|
||||
// Invariant: the policy NEVER emits a replacement larger than the cap. When
|
||||
// the notice alone exceeds maxInlineBytes (a tiny cap or a long spill root),
|
||||
// there is no within-cap replacement, so keep the inline result — spilling
|
||||
// would break the advertised context cap. (A within-cap replacement is
|
||||
// always smaller than the original, which is > cap by the entry condition,
|
||||
// so this one check subsumes "not smaller than the original" too. The spill
|
||||
// file already written is a harmless orphan; cleanup is deferred.)
|
||||
if (Buffer.byteLength(replacedText, 'utf8') > maxInlineBytes) {
|
||||
ctx.logger.warn(`spill-policy: spill notice for ${exec.name} exceeds maxInlineBytes; keeping the inline result`)
|
||||
return decision
|
||||
}
|
||||
const replaced: ContentBlock[] = [{ type: 'text', text: replacedText }]
|
||||
return { kind: 'accept', content: replaced, ...decision.additionalContexts ? { additionalContexts: decision.additionalContexts } : {} }
|
||||
})
|
||||
}
|
||||
26
packages/spill/spill-policy/src/types.ts
Normal file
26
packages/spill/spill-policy/src/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Vocabulary for the spill-policy plugin: the minimal structural view of a tool
|
||||
* execution the policy needs to derive the owning session for a spill artifact.
|
||||
*
|
||||
* `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies this shape, so the policy
|
||||
* reads `exec` straight through without importing `dsh-tools` or `dsh-agent`.
|
||||
* Only the session HEADER id is read — the same identity every other subsystem
|
||||
* keys off (see `dsh-tool-bash`'s owner derivation).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-spill-policy/types
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Minimal structural view of a tool execution: the owning session's header id, when present. */
|
||||
export interface SpillPolicyExec {
|
||||
/** The agent on whose behalf the call runs, when there is one. */
|
||||
agent?: {
|
||||
session: {
|
||||
header: {
|
||||
/** The canonical session identity — the spill owner. */
|
||||
id: SessionId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
276
packages/spill/spill-policy/tests/spill-policy.spec.ts
Normal file
276
packages/spill/spill-policy/tests/spill-policy.spec.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Tests for the spill-policy PLUGIN. It registers no service, only the
|
||||
* `tools/post-execute` transformer. We drive real tools through
|
||||
* `ctx.tools.execute(...)` and assert: disabled mode is a true no-op, an
|
||||
* oversized plain-text result is spilled and replaced with a preview + locator,
|
||||
* a small result and a non-text result pass through, `read` is skipped, and a
|
||||
* `saveText` failure / missing backend / missing owner all preserve the original
|
||||
* result without an `isError`.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
|
||||
|
||||
/** A stub spill backend recording its saves; `fail` exercises the best-effort fallback. */
|
||||
class StubStore extends SpillStore {
|
||||
saves: SaveTextSpill[] = []
|
||||
fail = false
|
||||
|
||||
async saveText(input: SaveTextSpill): Promise<SpillRef> {
|
||||
if (this.fail) throw new Error('disk full')
|
||||
this.saves.push(input)
|
||||
return {
|
||||
locator: SpillLocator(`/spill/${input.suggestedName}`),
|
||||
bytes: Buffer.byteLength(input.content, 'utf8'),
|
||||
retrievalHint: 'Use the stub retrieval path.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A tool returning `text` verbatim (name configurable so we can register `read`). */
|
||||
function textTool(name: string, text: string) {
|
||||
return defineTool({
|
||||
name,
|
||||
description: name,
|
||||
parameters: {},
|
||||
async execute(): Promise<ContentBlock[]> { return [{ type: 'text', text }] },
|
||||
})
|
||||
}
|
||||
|
||||
/** A minimal exec carrying a session header id (the spill owner). */
|
||||
function exec(name: string, session = 's1'): ToolExecution {
|
||||
// Only agent.session.header.id is read by the policy; a structural stub suffices.
|
||||
const agent = { session: { header: { id: SessionId(session) } } }
|
||||
return { callId: CallId(`call-${name}`), name, arguments: {}, agent } as unknown as ToolExecution
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a context with tools + the policy, and optionally a spill backend.
|
||||
* Returns the context and the backend handle (undefined when `withSpill` false).
|
||||
*/
|
||||
async function setup(config: SpillPolicy.Config, withSpill = true): Promise<{ ctx: Context; spill?: StubStore; fiber: Awaited<ReturnType<Context['plugin']>> }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
let spill: StubStore | undefined
|
||||
if (withSpill) {
|
||||
await ctx.plugin(StubStore)
|
||||
spill = ctx.spillStore as StubStore
|
||||
}
|
||||
const fiber = await ctx.plugin(SpillPolicy, config)
|
||||
return { ctx, fiber, ...spill ? { spill } : {} }
|
||||
}
|
||||
|
||||
/** Flatten a result's text blocks. */
|
||||
function textOf(content: ContentBlock[]): string {
|
||||
return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
describe('disabled mode', () => {
|
||||
it('registers no post-execute listener when maxInlineBytes is omitted', async () => {
|
||||
const { ctx, spill } = await setup({})
|
||||
ctx.tools.register(textTool('big', 'x'.repeat(1000)))
|
||||
const result = await ctx.tools.execute(exec('big'))
|
||||
expect(textOf(result.content)).toBe('x'.repeat(1000))
|
||||
expect(result.isError).toBe(false)
|
||||
expect(spill?.saves).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loader export shape', () => {
|
||||
it('has no default export and keeps name/inject/Config through unwrapExports', () => {
|
||||
expect('default' in SpillPolicy).toBe(false)
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(SpillPolicy) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(SpillPolicy)
|
||||
expect(unwrapped.name).toBe('spill-policy')
|
||||
expect(unwrapped.inject).toEqual(['tools'])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('config validation', () => {
|
||||
it('rejects a negative maxInlineBytes at load', async () => {
|
||||
await expect(setup({ maxInlineBytes: -1 })).rejects.toThrow(/non-negative integer/)
|
||||
})
|
||||
|
||||
it('rejects a fractional maxInlineBytes at load', async () => {
|
||||
await expect(setup({ maxInlineBytes: 1.5 })).rejects.toThrow(/non-negative integer/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('oversized plain-text replacement', () => {
|
||||
it('spills the full text and replaces the result with a preview + locator within the cap', async () => {
|
||||
const { ctx, spill } = await setup({ maxInlineBytes: 200 })
|
||||
const body = 'HEAD'.repeat(200) + 'TAIL'.repeat(200) // 1600 bytes > 200
|
||||
ctx.tools.register(textTool('big', body))
|
||||
const result = await ctx.tools.execute(exec('big'))
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(spill?.saves).toHaveLength(1)
|
||||
expect(spill?.saves[0]?.content).toBe(body)
|
||||
expect(spill?.saves[0]?.source.toolName).toBe('big')
|
||||
expect(spill?.saves[0]?.suggestedName).toBe('big.txt')
|
||||
expect(spill?.saves[0]?.owner.sessionId).toBe('s1')
|
||||
|
||||
const text = textOf(result.content)
|
||||
expect(text).not.toBe(body)
|
||||
expect(text.startsWith('HEAD')).toBe(true)
|
||||
expect(text).toContain('Full formatted result stored at: /spill/big.txt')
|
||||
expect(text).toContain('Use the stub retrieval path.')
|
||||
expect(text).toContain('Omitted')
|
||||
// The replacement (preview + blank line + notice) stays within the cap and
|
||||
// is smaller than the original — the whole point of spilling.
|
||||
expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(200)
|
||||
expect(Buffer.byteLength(text, 'utf8')).toBeLessThan(body.length)
|
||||
})
|
||||
|
||||
it('keeps the inline result when the notice-only replacement would exceed the cap', async () => {
|
||||
// A body just over a tiny cap: the notice alone is larger than the cap, so
|
||||
// there is no within-cap replacement — the policy keeps the inline result.
|
||||
const { ctx } = await setup({ maxInlineBytes: 4 })
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const body = 'xxxxx' // 5 bytes > 4, but far shorter than the notice
|
||||
ctx.tools.register(textTool('big', body))
|
||||
const result = await ctx.tools.execute(exec('big'))
|
||||
expect(textOf(result.content)).toBe(body)
|
||||
expect(warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a small plain-text result unchanged', async () => {
|
||||
const { ctx, spill } = await setup({ maxInlineBytes: 1000 })
|
||||
ctx.tools.register(textTool('small', 'tiny'))
|
||||
const result = await ctx.tools.execute(exec('small'))
|
||||
expect(textOf(result.content)).toBe('tiny')
|
||||
expect(spill?.saves).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('leaves a result with a non-text block unchanged', async () => {
|
||||
const { ctx, spill } = await setup({ maxInlineBytes: 5 })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'mixed',
|
||||
description: 'mixed',
|
||||
parameters: {},
|
||||
async execute(): Promise<ContentBlock[]> {
|
||||
return [{ type: 'text', text: 'x'.repeat(100) }, { type: 'reasoning', text: 'why' }]
|
||||
},
|
||||
}))
|
||||
const result = await ctx.tools.execute(exec('mixed'))
|
||||
expect(spill?.saves).toHaveLength(0)
|
||||
expect(result.content).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('read skip', () => {
|
||||
it('never spills the read tool result (avoids a read → spill → read loop)', async () => {
|
||||
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
|
||||
ctx.tools.register(textTool('read', 'x'.repeat(1000)))
|
||||
const result = await ctx.tools.execute(exec('read'))
|
||||
expect(textOf(result.content)).toBe('x'.repeat(1000))
|
||||
expect(spill?.saves).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('best-effort fallback', () => {
|
||||
it('keeps the original result when saveText fails', async () => {
|
||||
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
|
||||
spill!.fail = true
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
ctx.tools.register(textTool('big', 'x'.repeat(1000)))
|
||||
const result = await ctx.tools.execute(exec('big'))
|
||||
expect(textOf(result.content)).toBe('x'.repeat(1000))
|
||||
expect(result.isError).toBe(false)
|
||||
expect(warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the original result when no spill backend is loaded', async () => {
|
||||
const { ctx } = await setup({ maxInlineBytes: 10 }, false)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
ctx.tools.register(textTool('big', 'x'.repeat(1000)))
|
||||
const result = await ctx.tools.execute(exec('big'))
|
||||
expect(textOf(result.content)).toBe('x'.repeat(1000))
|
||||
expect(warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the original result when the call has no session owner', async () => {
|
||||
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
ctx.tools.register(textTool('big', 'x'.repeat(1000)))
|
||||
const result = await ctx.tools.execute({ callId: CallId('c'), name: 'big', arguments: {} })
|
||||
expect(textOf(result.content)).toBe('x'.repeat(1000))
|
||||
expect(spill?.saves).toHaveLength(0)
|
||||
expect(warn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('composition', () => {
|
||||
it('bounds content a downstream post-execute listener replaced', async () => {
|
||||
const { ctx, spill } = await setup({ maxInlineBytes: 200 })
|
||||
// A later-registered listener replaces the (small) tool result with a big one;
|
||||
// the policy delegated via next(), so it bounds the replacement.
|
||||
ctx.on('tools/post-execute', async (_e, _r, _next) =>
|
||||
({ kind: 'accept', content: [{ type: 'text', text: 'z'.repeat(500) }] }))
|
||||
ctx.tools.register(textTool('small', 'tiny'))
|
||||
const result = await ctx.tools.execute(exec('small'))
|
||||
expect(spill?.saves[0]?.content).toBe('z'.repeat(500))
|
||||
expect(textOf(result.content)).toContain('Full formatted result stored at')
|
||||
})
|
||||
|
||||
it('preserves downstream accept-decision contexts when spilling', async () => {
|
||||
const { ctx } = await setup({ maxInlineBytes: 200 })
|
||||
const context = { content: [{ type: 'text' as const, text: 'note' }], source: { kind: 'plugin' as const, plugin: 'test' } }
|
||||
ctx.on('tools/post-execute', async (_e, _r, _next) =>
|
||||
({ kind: 'accept', additionalContexts: [context] }))
|
||||
ctx.tools.register(textTool('big', 'x'.repeat(1000)))
|
||||
const result = await ctx.tools.execute(exec('big'))
|
||||
expect(textOf(result.content)).toContain('Full formatted result stored at')
|
||||
expect(result.additionalContexts).toEqual([context])
|
||||
})
|
||||
})
|
||||
|
||||
describe('cap invariant', () => {
|
||||
it('keeps the inline result when the notice alone exceeds the cap, even for a large original', async () => {
|
||||
// A large body (so it is well over the cap) but a cap smaller than the
|
||||
// notice itself: there is no within-cap replacement, so the policy must keep
|
||||
// the inline result rather than emit content over maxInlineBytes.
|
||||
const { ctx } = await setup({ maxInlineBytes: 8 })
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const body = 'x'.repeat(5000)
|
||||
ctx.tools.register(textTool('big', body))
|
||||
const result = await ctx.tools.execute(exec('big'))
|
||||
expect(textOf(result.content)).toBe(body)
|
||||
expect(warn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposal (HMR safety)', () => {
|
||||
it('stops transforming oversized results after the plugin fiber is disposed', async () => {
|
||||
const { ctx, spill, fiber } = await setup({ maxInlineBytes: 200 })
|
||||
const body = 'HEAD'.repeat(200) + 'TAIL'.repeat(200)
|
||||
ctx.tools.register(textTool('big', body))
|
||||
|
||||
// Live: the listener spills and replaces.
|
||||
const before = await ctx.tools.execute(exec('big'))
|
||||
expect(textOf(before.content)).toContain('Full formatted result stored at')
|
||||
expect(spill?.saves).toHaveLength(1)
|
||||
|
||||
// After disposal the listener is gone — the result passes through untouched
|
||||
// and nothing more is spilled (no leaked registration across reload).
|
||||
await fiber.dispose()
|
||||
const after = await ctx.tools.execute(exec('big'))
|
||||
expect(textOf(after.content)).toBe(body)
|
||||
expect(spill?.saves).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
18
packages/spill/spill-policy/tsconfig.json
Normal file
18
packages/spill/spill-policy/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../util/retention" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../spill" },
|
||||
{ "path": "../../core/tools" }
|
||||
]
|
||||
}
|
||||
36
packages/spill/spill/README.md
Normal file
36
packages/spill/spill/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# @deepseek-ai/dsh-spill
|
||||
|
||||
The **spill storage seam**: an abstract `SpillStore` service (`ctx.spillStore`) defining WHAT a spill backend does — persist a tool's oversized text and return a model-facing locator plus retrieval guidance — without saying HOW.
|
||||
|
||||
This package is one third of the spill capability, split so each concern evolves (and swaps) independently:
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-spill` (this) | the interface: abstract service + vocabulary types |
|
||||
| `@deepseek-ai/dsh-spill-local` | an implementation: private session-scoped files on the host filesystem |
|
||||
| `@deepseek-ai/dsh-spill-policy` | the tool-result policy that spills oversized final results |
|
||||
|
||||
The split mirrors the bash/fs seams. A future remote or virtual backend (e.g. a `spill://…` URI, a database key, or a backend-specific retrieval tool) implements this interface without touching the policy plugin.
|
||||
|
||||
## Service API (`ctx.spillStore`)
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `saveText(input)` | Persist `input.content` verbatim; resolves with a `SpillRef` (opaque locator, exact bytes written, and retrieval hint). **Rejects on a real storage failure** (permissions, ENOSPC, backend unavailable) — the caller decides how to degrade. |
|
||||
|
||||
Storage is grouped by the request's `owner` session as a save-time namespace; the backend chooses its own private representation and may derive names from — never trust as a path — the caller's `suggestedName`. The seam owns storage only: NO retention policy (that is [`@deepseek-ai/dsh-retention`](../../util/retention)), NO tool-result replacement (that is `@deepseek-ai/dsh-spill-policy`), NO retrieval/search API (the backend's `retrievalHint` tells the model what to do with the locator).
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`SaveTextSpill` (owner, source, suggestedName, content) is the request; `SpillRef` (locator, bytes, retrievalHint) is the result. `SpillLocator` is [branded](../../util/brand) and rendered to the model as an opaque string — a local path for `dsh-spill-local`, but a future backend may return a URI, key, or command token without changing policy/tool consumers. `SpillOwner.sessionId` is the save-time storage namespace: forked sessions inherit existing locators from the seeded log without copying or re-owning them, and new spills after the fork use the child session id. `SpillSource` (toolName, callId, label) is descriptive provenance for backend naming and inspection, not access control. See `src/types.ts` for the full contracts.
|
||||
|
||||
See the [tool output spill RFC](../../../docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design rationale, including why creation belongs to the runtime spill seam rather than the model-facing `write` tool.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through spill consumers that render a backend locator and retrieval guidance.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The seam has no retrieval or deletion API** — consumers can only render the backend's locator and guidance; lifecycle and access semantics remain backend-specific.
|
||||
- **Storage is not access control** — `SpillOwner` namespaces writes but does not authorize reads of a locator; each backend and retrieval consumer must enforce its own boundary.
|
||||
36
packages/spill/spill/package.json
Normal file
36
packages/spill/spill/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-spill",
|
||||
"description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
58
packages/spill/spill/src/index.ts
Normal file
58
packages/spill/spill/src/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The spill storage seam (`ctx.spillStore`): an abstract service defining WHAT a
|
||||
* spill backend does — persist a tool's oversized text and return a model-facing
|
||||
* locator plus retrieval guidance — without saying HOW. Implementations
|
||||
* subclass {@link SpillStore} and register as the `spillStore` service;
|
||||
* `@deepseek-ai/dsh-spill-local` (host filesystem) is the first.
|
||||
*
|
||||
* The seam is deliberately minimal: `saveText` and nothing else. It owns NO
|
||||
* retention policy (that is `@deepseek-ai/dsh-retention`), NO tool-result
|
||||
* replacement (that is `@deepseek-ai/dsh-spill-policy`), and NO retrieval or
|
||||
* search API. The backend supplies the locator and retrieval hint appropriate
|
||||
* for its storage substrate.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-spill
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { SaveTextSpill, SpillRef } from './types.ts'
|
||||
|
||||
export { SpillLocator } from './types.ts'
|
||||
export type { SaveTextSpill, SpillOwner, SpillRef, SpillSource } from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
spillStore: SpillStore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract spill storage service. Subclass, implement {@link 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:
|
||||
* - {@link 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 {@link 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).
|
||||
*/
|
||||
export abstract class SpillStore extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'spillStore')
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist `input.content` to a session-scoped spill artifact.
|
||||
* @param input - the owner, provenance, suggested name, and full text to save.
|
||||
* @returns the saved artifact's {@link SpillRef}; rejects on a storage failure.
|
||||
*/
|
||||
abstract saveText(input: SaveTextSpill): Promise<SpillRef>
|
||||
}
|
||||
|
||||
export default SpillStore
|
||||
73
packages/spill/spill/src/types.ts
Normal file
73
packages/spill/spill/src/types.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Vocabulary for the spill storage seam. Types only — the abstract service
|
||||
* lives in `./index.ts`, implementations in sibling packages
|
||||
* (`@deepseek-ai/dsh-spill-local` first).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-spill/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Opaque model-facing handle for one spilled artifact. A local backend may use a
|
||||
* filesystem path; a remote or database backend may use a URI or key. Consumers
|
||||
* render it with {@link SpillRef.retrievalHint}, but do not parse it.
|
||||
*/
|
||||
export type SpillLocator = Branded<'SpillLocator'>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link SpillLocator}.
|
||||
*
|
||||
* @param locator The backend-produced locator string to brand.
|
||||
* @returns The branded spill locator.
|
||||
*/
|
||||
export function SpillLocator(locator: string): SpillLocator {
|
||||
return locator as SpillLocator
|
||||
}
|
||||
|
||||
/**
|
||||
* Save-time storage namespace for a spilled artifact. The session id lets a
|
||||
* backend group storage under the producing session, but the returned
|
||||
* {@link SpillLocator} is the model-facing handle. Forked sessions inherit
|
||||
* locators already present in the seeded log; those artifacts are not copied or
|
||||
* re-owned, and spills produced after the fork use the child session id.
|
||||
*/
|
||||
export interface SpillOwner {
|
||||
sessionId: SessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Provenance of one spilled artifact — recorded by the backend for a readable
|
||||
* filename and inspection. Not interpreted for access control; purely
|
||||
* descriptive.
|
||||
*/
|
||||
export interface SpillSource {
|
||||
/** The tool whose result was spilled (e.g. `web_fetch`). */
|
||||
toolName: string
|
||||
/** The model-issued call id the result belongs to. */
|
||||
callId: CallId
|
||||
/** A short human label for the artifact (e.g. `result`). */
|
||||
label: string
|
||||
}
|
||||
|
||||
/** One request to persist text to a spill artifact. */
|
||||
export interface SaveTextSpill {
|
||||
owner: SpillOwner
|
||||
source: SpillSource
|
||||
/**
|
||||
* A caller-suggested base name (e.g. `web_fetch.txt`). The backend sanitizes
|
||||
* it to a single safe path segment before use — it is a hint, never a path.
|
||||
*/
|
||||
suggestedName: string
|
||||
/** The full text to persist (UTF-8). */
|
||||
content: string
|
||||
}
|
||||
|
||||
/** A saved spill artifact: its locator, byte length, and backend-specific retrieval guidance. */
|
||||
export interface SpillRef {
|
||||
locator: SpillLocator
|
||||
bytes: number
|
||||
retrievalHint: string
|
||||
}
|
||||
60
packages/spill/spill/tests/service.spec.ts
Normal file
60
packages/spill/spill/tests/service.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Tests for the spill seam INTERFACE: a minimal concrete subclass registers as
|
||||
* `ctx.spillStore`, a second load throws (duplicate service), and disposal
|
||||
* releases the service. The storage behavior is the implementation's concern
|
||||
* (`@deepseek-ai/dsh-spill-local`); here we only pin the seam contract.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
|
||||
/** Minimal concrete backend: records the last request, returns a fixed ref. */
|
||||
class StubStore extends SpillStore {
|
||||
last: SaveTextSpill | undefined
|
||||
|
||||
async saveText(input: SaveTextSpill): Promise<SpillRef> {
|
||||
this.last = input
|
||||
return {
|
||||
locator: SpillLocator(`/stub/${input.suggestedName}`),
|
||||
bytes: Buffer.byteLength(input.content, 'utf8'),
|
||||
retrievalHint: 'Use the stub reader.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function request(content: string): SaveTextSpill {
|
||||
return {
|
||||
owner: { sessionId: SessionId('s1') },
|
||||
source: { toolName: 'web_fetch', callId: CallId('c1'), label: 'result' },
|
||||
suggestedName: 'web_fetch.txt',
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
describe('spill seam', () => {
|
||||
it('registers as ctx.spillStore and saves text', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubStore)
|
||||
const ref = await ctx.spillStore.saveText(request('hello'))
|
||||
expect(ref).toEqual({ locator: '/stub/web_fetch.txt', bytes: 5, retrievalHint: 'Use the stub reader.' })
|
||||
expect((ctx.spillStore as StubStore).last?.content).toBe('hello')
|
||||
})
|
||||
|
||||
it('rejects a second implementation (one per context)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubStore)
|
||||
await expect(ctx.plugin(StubStore)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('releases the service on disposal', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(StubStore)
|
||||
expect(ctx.spillStore).toBeInstanceOf(StubStore)
|
||||
await fiber.dispose()
|
||||
expect((ctx as Context & { spillStore?: unknown }).spillStore).toBeUndefined()
|
||||
})
|
||||
})
|
||||
15
packages/spill/spill/tsconfig.json
Normal file
15
packages/spill/spill/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../util/brand" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/session" }
|
||||
]
|
||||
}
|
||||
@@ -168,6 +168,9 @@ export interface RunOptions {
|
||||
export async function runScenario(input: InputScript, opts: RunOptions): Promise<RunResult> {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'acp-snap-cwd-'))
|
||||
const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-'))
|
||||
// Fixed path length: spill-policy budgets the preview against the REAL path
|
||||
// before stdout normalization, so tmpdir() length differences churn goldens.
|
||||
const spillRoot = '/tmp/dsh-acp-snapshot-spill'
|
||||
// Everything past the temp-dir creation runs under a try/finally that always
|
||||
// removes both dirs — so a failure in workspace seeding, spawn, or any step
|
||||
// never leaks them (the "e2e tests own their resources" rule).
|
||||
@@ -187,6 +190,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
|
||||
DSH_SNAPSHOT: opts.mode,
|
||||
DSH_SNAPSHOT_FILE: opts.fixtureFile,
|
||||
DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot,
|
||||
DSH_SNAPSHOT_SPILL_ROOT: spillRoot,
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {},
|
||||
@@ -291,6 +295,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
|
||||
}
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
await rm(sessionsRoot, { recursive: true, force: true })
|
||||
await rm(spillRoot, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,16 @@ const MESSAGE_PREFIX = '{{messagePrefix}}'
|
||||
|
||||
/** A UUID v4 string, the shape `randomUUID()` produces for session ids. */
|
||||
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
|
||||
const LOCAL_SPILL_PATH_RE = new RegExp(
|
||||
String.raw`\{\{cwd\}\}/\.spill/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
|
||||
+ String.raw`(?=\. Use read with offset/limit|[\s)]|$)`,
|
||||
'g',
|
||||
)
|
||||
const SNAPSHOT_SPILL_PATH_RE = new RegExp(
|
||||
String.raw`/tmp/dsh-acp-snapshot-spill/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
|
||||
+ String.raw`(?=\. Use read with offset/limit|[\s)]|$)`,
|
||||
'g',
|
||||
)
|
||||
|
||||
/** Inputs the normalizers need to recognize a run's volatile values. */
|
||||
export interface NormalizeContext {
|
||||
@@ -29,6 +39,9 @@ function scrubString(value: string, ctx: NormalizeContext): string {
|
||||
// cwd first (longest, most specific), then explicit session ids, then any
|
||||
// residual UUID (covers ids that appear in places we didn't enumerate).
|
||||
out = out.split(ctx.cwd).join(CWD)
|
||||
out = out.split(`/private${CWD}`).join(CWD)
|
||||
out = out.replace(LOCAL_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`)
|
||||
out = out.replace(SNAPSHOT_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`)
|
||||
for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID)
|
||||
out = out.replace(UUID_RE, SESSION_ID)
|
||||
return out
|
||||
|
||||
@@ -93,6 +93,52 @@ describe('normalizeSessionLog', () => {
|
||||
expect(out).not.toContain(ctx.cwd)
|
||||
})
|
||||
|
||||
it('scrubs random local spill paths under the snapshot cwd', () => {
|
||||
const ev = JSON.stringify({
|
||||
type: 'tool/result', seq: 2, time: 5,
|
||||
data: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Full formatted result stored at: ${ctx.cwd}/.spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`,
|
||||
}],
|
||||
},
|
||||
})
|
||||
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
|
||||
expect(out).toContain('{{spillLocator:bash.txt}}')
|
||||
expect(out).not.toContain('session-c22bc3f1d2af')
|
||||
expect(out).not.toContain('8a7b6c5d4e3f')
|
||||
})
|
||||
|
||||
it('scrubs macOS /private aliases for local spill paths', () => {
|
||||
const ev = JSON.stringify({
|
||||
type: 'tool/result', seq: 2, time: 5,
|
||||
data: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Full formatted result stored at: /private${ctx.cwd}/.spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`,
|
||||
}],
|
||||
},
|
||||
})
|
||||
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
|
||||
expect(out).toContain('{{spillLocator:bash.txt}}')
|
||||
expect(out).not.toContain('/private{{spillLocator')
|
||||
})
|
||||
|
||||
it('scrubs fixed snapshot spill paths', () => {
|
||||
const ev = JSON.stringify({
|
||||
type: 'tool/result', seq: 2, time: 5,
|
||||
data: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.',
|
||||
}],
|
||||
},
|
||||
})
|
||||
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
|
||||
expect(out).toContain('{{spillLocator:bash.txt}}')
|
||||
expect(out).not.toContain('/tmp/dsh-acp-snapshot-spill')
|
||||
})
|
||||
|
||||
it('scrubs the session id in the header', () => {
|
||||
const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx)
|
||||
expect(out).toContain('{{sessionId}}')
|
||||
|
||||
@@ -7,7 +7,10 @@ Zero-dependency primitives shared across the other groups. A package lands here
|
||||
| `brand/` | The type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) |
|
||||
| `paths/` | Shared filesystem path constants and helpers for harness user data |
|
||||
| `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability |
|
||||
| `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool |
|
||||
|
||||
`dsh-brand` is the canonical case: it owns ONLY the `Branded<B>` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`.
|
||||
|
||||
`dsh-timeout` follows the same shape for the timeout family: `dsh-bash` and `dsh-web-fetch-local` each fuse a caller's cancellation with a deadline and later classify "timed out" vs "cancelled" by depending on `dsh-timeout` alone. It deliberately owns only the timing/classification half — the *termination* (SIGKILL a process group, tear down a fetch socket) stays in each capability, because no shared layer can own every capability's kill (see [the timeout-library RFC](../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
|
||||
|
||||
`dsh-retention` is the same split for bounded tool output: a tool (`glob`/`grep`/`bash`/`web_fetch`/`web_search`) feeds items or text into a retainer and gets back what it kept and exactly what it omitted — while grouping, exit codes, provider errors, and recovery prose stay tool-owned. It deliberately owns only the retention mechanic; `truncated` is a budget fact, never an "incomplete inspection" state (see [the retention-library RFC](../../docs/rfc/implemented/architecture/2026-07-06-tool-result-retention-library.md)).
|
||||
|
||||
91
packages/util/retention/README.md
Normal file
91
packages/util/retention/README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# dsh-retention
|
||||
|
||||
A dependency-light **retention** library: bounded model-facing output for tools that must cap how much context they return. A caller feeds items or text chunks into a bounded object, then gets the retained content plus exact omission metadata.
|
||||
|
||||
The library owns **only** the mechanical question *"what did we keep, and what did we omit?"*. Tool-specific code keeps its business semantics: file grouping, line numbering, exit codes, provider error states, per-line preview truncation, spill files, and the model-facing prose. This is the boundary the [RFC](../../../docs/rfc/implemented/architecture/2026-07-06-tool-result-retention-library.md) draws.
|
||||
|
||||
It is a **library, not a service or plugin**: no `ctx`, registers nothing, emits no events. The only state is per-retainer (one accumulation), never cross-call. Tool packages import it directly.
|
||||
|
||||
## Surface
|
||||
|
||||
```ts
|
||||
import {
|
||||
ItemRetainer, TextRetainer,
|
||||
describeOmitted, formatRetentionNotice,
|
||||
} from '@deepseek-ai/dsh-retention'
|
||||
import type {
|
||||
Omitted, PushDecision, RetainedItems, RetainedText,
|
||||
ItemRetentionStrategy, TextRetentionStrategy, RetentionNotice,
|
||||
} from '@deepseek-ai/dsh-retention'
|
||||
```
|
||||
|
||||
| Export | Role |
|
||||
|---|---|
|
||||
| `ItemRetainer<T>` | Bounds ordered logical units (paths, grep matches, sources). `head` only in v1. `push()` → `PushDecision`; `finish()` → `RetainedItems<T>`. |
|
||||
| `TextRetainer` | Bounds a byte-oriented text stream. `head` / `tail` / `headTail`, UTF-8 boundaries preserved at `finish()`. `push()` → `PushDecision`; `finish()` → `RetainedText`. |
|
||||
| `describeOmitted(omitted, unit)` | Standardized omission clause (`exact` prints a count; `unknown` does not). |
|
||||
| `formatRetentionNotice(notice, recovery)` | Joins the standardized omission clause with the tool's own recovery guidance. |
|
||||
| `Omitted` | `none` / `exact` / `unknown` — how much was omitted. |
|
||||
| `PushDecision` | `{ kept, truncated }` — the per-push retention result. |
|
||||
|
||||
## Resource Modes
|
||||
|
||||
The two retainers are separate names, not one generic collector, because they differ in **resource model**.
|
||||
|
||||
- **`ItemRetainer` bounds ordered logical units.** A search tool can collect a full result set for spill-file recovery while retaining only the first `maxItems` for the model-facing preview. The omission count is exact because the caller keeps feeding every observed item.
|
||||
- **`TextRetainer` bounds byte-oriented text.** `head`, `tail`, and `headTail` preserve UTF-8 boundaries at `finish()`; `headTail` is the shape `dsh-spill-policy` uses to build a bounded preview around a spill-file notice.
|
||||
|
||||
## `truncated` is a budget fact, never "incomplete"
|
||||
|
||||
`truncated` means *the retainer omitted otherwise-available content because of a budget*. It does **not** mean the upstream was incomplete. Permission failures, skipped binary files, provider partial failures, unreadable candidates, and invalid UTF-8 stay in tool-domain fields — never folded into `truncated`. Conflating the two is the bug this library's naming most invites; keep them separate.
|
||||
|
||||
## Bytes, not characters
|
||||
|
||||
Text caps and `omittedBytes` count **bytes**, for process/body safety (a child's pipe and an HTTP body are byte streams). A chunk that straddles a codepoint is handled: `finish()` trims a partial codepoint at each cut so the returned text never introduces a replacement char at the boundary, and the two sides are decoded separately so a codepoint is never reconstructed across the omitted middle. Character- or line-level preview budgets are a separate, tool-owned concern.
|
||||
|
||||
## Tool mappings
|
||||
|
||||
Every current retention consumer maps to the library below. A broad migration is out of scope for the library's first landing — these are the intended shapes.
|
||||
|
||||
| Tool | Retainer & strategy | Notes |
|
||||
|---|---|---|
|
||||
| `glob` | `ItemRetainer<FsGlobEntry>`, `head` | Collect the full sorted path list for a spill file while retaining the first page inline. Path mapping, skipped candidates, and `incomplete` stay outside. |
|
||||
| `grep` | `ItemRetainer<FlatGrepMatch>`, `head` | Collect matches for a spill file while retaining the first page inline. Per-match preview truncation, grouping, sorting, and `incomplete` stay outside. |
|
||||
| `bash` | `TextRetainer`, `tail` or `headTail` | Executor still owns spill files, exit status, signal, timeout, and background tasks. |
|
||||
| `web_fetch` | `TextRetainer`, `head` or `headTail` | Provider/resource caps stay provider facts; the retainer supplies only retained text and omission metadata. |
|
||||
| `web_search` | `ItemRetainer<WebSearchSource>`, `head` | Standardizes the "sources capped" notice when providers return more sources than the model-facing result should include. |
|
||||
|
||||
`read` is **intentionally out of scope for v1.** Its `read-render` helper owns a file-specific pagination contract — `offset`/`limit`, line numbers, `totalLines`, offset-out-of-range errors, per-line preview truncation, a byte cap over the selected window — which is a line-window renderer, not generic retention. A single `Omitted` count cannot represent both sides of a line window.
|
||||
|
||||
## Usage shape
|
||||
|
||||
```ts ignore-check
|
||||
// glob: keep the first page inline while still collecting the full list for spill.
|
||||
const retainer = new ItemRetainer<FsGlobEntry>({ kind: 'head', maxItems: globMaxResults })
|
||||
const allEntries: FsGlobEntry[] = []
|
||||
for await (const entry of candidates) {
|
||||
allEntries.push(entry)
|
||||
retainer.push(entry)
|
||||
}
|
||||
const { items, truncated, omitted } = retainer.finish()
|
||||
|
||||
// bash: keep a head + tail, read to process exit.
|
||||
const out = new TextRetainer({ kind: 'headTail', headBytes: headCap, tailBytes: tailCap })
|
||||
child.stdout.on('data', (chunk: Buffer) => { out.push(chunk) })
|
||||
const { text, omittedBytes } = out.finish()
|
||||
|
||||
// A footer: the library standardizes the omission clause; the tool owns recovery words.
|
||||
const footer = formatRetentionNotice(
|
||||
{ scope: 'grep', strategy: 'head', unit: 'items', limit: grepMaxMatches, kept: items.length, omitted },
|
||||
({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`,
|
||||
)
|
||||
```
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through tool consumers that render retained content and omission metadata.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Item retention supports `head` only** — tail, head/tail, pagination, grouping, and provider-completeness semantics remain tool-owned.
|
||||
- **Text retention is byte-oriented** — line and character windows such as `read` pagination require a separate renderer, and a cut may discard partial UTF-8 boundary bytes to keep returned text valid.
|
||||
30
packages/util/retention/package.json
Normal file
30
packages/util/retention/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-retention",
|
||||
"description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
444
packages/util/retention/src/index.ts
Normal file
444
packages/util/retention/src/index.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* A dependency-light **retention** library: bounded model-facing output for
|
||||
* tools that must cap how much context they return. A caller feeds items or
|
||||
* text chunks into a bounded object, then gets the retained content plus exact
|
||||
* omission metadata ({@link RetainedItems} / {@link RetainedText}).
|
||||
*
|
||||
* The library owns ONLY the mechanical question "what did we keep, what did we
|
||||
* omit?". Tool-specific code still owns
|
||||
* business semantics: file grouping, line numbering, exit codes, provider error
|
||||
* states, per-line preview truncation, spill files, and the model-facing prose.
|
||||
* In particular {@link RetainedText.truncated}/{@link RetainedItems.truncated}
|
||||
* means "the retainer omitted otherwise-available content because of a budget" —
|
||||
* NOT "the upstream was incomplete". Permission failures, skipped binaries,
|
||||
* provider partial failures, and unreadable candidates stay in tool-domain
|
||||
* fields, never folded into `truncated`.
|
||||
*
|
||||
* This is deliberately a library, not a cordis service or plugin: it takes no
|
||||
* `ctx`, registers nothing, and emits no events. The two retainers are the only
|
||||
* stateful pieces and their state is per-instance (one accumulation), never
|
||||
* cross-call. Tool packages import it directly when they need bounded output.
|
||||
*
|
||||
* The two retainers differ in resource model, which is why they are two names
|
||||
* rather than one generic collector:
|
||||
* - {@link ItemRetainer} bounds ordered logical units (paths, grep matches,
|
||||
* search sources). `head` retention only in v1.
|
||||
* - {@link TextRetainer} bounds byte-oriented text streams (bash stdout/stderr,
|
||||
* web bodies). `head` / `tail` / `headTail`, preserving UTF-8 boundaries at
|
||||
* {@link TextRetainer.finish}.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-retention
|
||||
*/
|
||||
|
||||
/**
|
||||
* How much content the retainer omitted.
|
||||
*
|
||||
* `exact` is the normal retainer shape: every unit/byte was observed, so the
|
||||
* omitted count is precise. `unknown` is reserved for a caller that omits
|
||||
* without a count; the retainers themselves never return it.
|
||||
*/
|
||||
export type Omitted =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'exact'; count: number }
|
||||
| { kind: 'unknown' }
|
||||
|
||||
/**
|
||||
* The caller receives this after each `push()`.
|
||||
*/
|
||||
export interface PushDecision {
|
||||
/** Was this whole unit / all of this chunk's bytes retained (nothing dropped)? */
|
||||
kept: boolean
|
||||
/** Cumulative: has the retainer omitted anything due to the budget yet? */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Final result for ordered logical units.
|
||||
*
|
||||
* `seen` means units OBSERVED by the retainer, not necessarily the total in the
|
||||
* upstream source. `kept` is `items.length`, surfaced explicitly so a notice
|
||||
* formatter need not re-count.
|
||||
*/
|
||||
export interface RetainedItems<T> {
|
||||
items: T[]
|
||||
truncated: boolean
|
||||
seen: number
|
||||
kept: number
|
||||
omitted: Omitted
|
||||
}
|
||||
|
||||
/**
|
||||
* Final result for text streams.
|
||||
*
|
||||
* The returned `text` is safe to hand to a formatter: the retainer adds no
|
||||
* tool-specific headers, exit markers, XML tags, or recovery instructions, and
|
||||
* `omittedBytes` counts BYTES (not characters or lines) — text retention is
|
||||
* byte-oriented for process/body safety. UTF-8 boundaries at each cut are
|
||||
* preserved, so `text` never carries a replacement char introduced by the cut
|
||||
* itself.
|
||||
*/
|
||||
export interface RetainedText {
|
||||
text: string
|
||||
truncated: boolean
|
||||
omittedBytes: Omitted
|
||||
}
|
||||
|
||||
/** Item retention strategy. Only `head` in v1; windows/grouped budgets wait for a second consumer. */
|
||||
export type ItemRetentionStrategy = {
|
||||
/** Keep the first `maxItems` units. Use for `glob`, `grep`, and web sources. */
|
||||
kind: 'head'
|
||||
maxItems: number
|
||||
}
|
||||
|
||||
/** Text retention strategy: keep a prefix, a suffix, or both, counted in bytes. */
|
||||
export 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
|
||||
}
|
||||
|
||||
/**
|
||||
* A neutral, tool-agnostic description of one retention outcome — the input to
|
||||
* {@link formatRetentionNotice}. It carries the mechanical facts (strategy,
|
||||
* unit, limit, kept count, {@link Omitted}); the tool supplies the recovery
|
||||
* words, because only the tool knows the recovery action ("narrow the pattern",
|
||||
* "fetch a more specific URL", "read the spill file").
|
||||
*/
|
||||
export interface RetentionNotice {
|
||||
/** Tool/scope label, e.g. `grep`, `web_fetch`, `bash stdout`. */
|
||||
scope: string
|
||||
strategy: 'head' | 'tail' | 'headTail'
|
||||
unit: 'items' | 'bytes' | 'chars' | 'lines'
|
||||
limit: number | { head: number; tail: number }
|
||||
kept: number
|
||||
omitted: Omitted
|
||||
}
|
||||
|
||||
/** Assert a budget field is a non-negative integer (the retainer request contract). */
|
||||
function assertBudget(value: number, name: string): void {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`${name} must be a non-negative integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds an ordered stream of logical units, keeping the first `maxItems`
|
||||
* ({@link ItemRetentionStrategy} `head`). `push()` reports, per unit, whether it
|
||||
* was kept and whether the retained result is now truncated.
|
||||
*
|
||||
* Grouping, sorting, path mapping, per-unit preview truncation, and any
|
||||
* `incomplete` state stay OUTSIDE the retainer: it counts and keeps, nothing
|
||||
* more. The caller pushes already-shaped units and, after {@link finish},
|
||||
* groups/sorts the retained subset itself.
|
||||
*/
|
||||
export class ItemRetainer<T> {
|
||||
private readonly maxItems: number
|
||||
private readonly items: T[] = []
|
||||
private seen = 0
|
||||
private omittedCount = 0
|
||||
|
||||
/** @param strategy Head strategy: `maxItems` (non-negative integer). */
|
||||
constructor(strategy: ItemRetentionStrategy) {
|
||||
assertBudget(strategy.maxItems, 'maxItems')
|
||||
this.maxItems = strategy.maxItems
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer one unit. Kept when the retainer is below `maxItems`; otherwise dropped
|
||||
* and counted as omitted. Callers keep pushing all observed units, so the final
|
||||
* {@link Omitted} count is exact.
|
||||
*
|
||||
* @param item The already-shaped logical unit (path, flat match, source).
|
||||
* @returns The per-push {@link PushDecision}.
|
||||
*/
|
||||
push(item: T): PushDecision {
|
||||
this.seen++
|
||||
if (this.items.length < this.maxItems) {
|
||||
// Reached only below the cap, before any omission (items only grow, the
|
||||
// cap is fixed), so nothing has been dropped yet: truncated is always false.
|
||||
this.items.push(item)
|
||||
return { kept: true, truncated: false }
|
||||
}
|
||||
this.omittedCount++
|
||||
return {
|
||||
kept: false,
|
||||
truncated: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize and report what was kept and omitted.
|
||||
*
|
||||
* @returns The {@link RetainedItems} snapshot (safe to group/sort downstream).
|
||||
*/
|
||||
finish(): RetainedItems<T> {
|
||||
const truncated = this.omittedCount > 0
|
||||
return {
|
||||
items: this.items,
|
||||
truncated,
|
||||
seen: this.seen,
|
||||
kept: this.items.length,
|
||||
omitted: truncated
|
||||
? { kind: 'exact', count: this.omittedCount }
|
||||
: { kind: 'none' },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder() // utf-8, non-fatal: internal malformed bytes → U+FFFD
|
||||
|
||||
/**
|
||||
* Drop a trailing incomplete UTF-8 sequence so a prefix cut never emits a
|
||||
* replacement char at the boundary. Walks back over continuation bytes
|
||||
* (`10xxxxxx`) to the lead byte; if fewer bytes follow it than the lead byte's
|
||||
* length declares, the sequence is incomplete and is trimmed. A complete tail,
|
||||
* or a run too long/short to be a valid lead, is returned untouched (any
|
||||
* genuinely malformed interior is left for the decoder to replace).
|
||||
*/
|
||||
function trimTrailingPartialUtf8(bytes: Uint8Array): Uint8Array {
|
||||
let i = bytes.length - 1
|
||||
// Continuation bytes are 0b10xxxxxx; scan back at most 3 (max sequence is 4).
|
||||
// Indices are bounds-checked by the loop guard, so the reads are in range (a
|
||||
// cast, not `!`, per the repo's no-non-null-assertion rule).
|
||||
while (i >= 0 && ((bytes[i] as number) & 0xc0) === 0x80 && bytes.length - i <= 3) i--
|
||||
if (i < 0) return bytes
|
||||
const lead = bytes[i] as number
|
||||
const expected = lead < 0x80 ? 1 : lead < 0xe0 ? 2 : lead < 0xf0 ? 3 : lead < 0xf8 ? 4 : 0
|
||||
// expected 0 → not a lead byte (stray continuation / invalid): leave it.
|
||||
if (expected === 0) return bytes
|
||||
return bytes.length - i < expected ? bytes.subarray(0, i) : bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop leading continuation bytes (`10xxxxxx`) so a suffix cut starts on a
|
||||
* lead/ASCII byte instead of mid-codepoint.
|
||||
*/
|
||||
function trimLeadingContinuationUtf8(bytes: Uint8Array): Uint8Array {
|
||||
let i = 0
|
||||
// i < length guards the read; cast rather than `!` (no-non-null-assertion).
|
||||
while (i < bytes.length && ((bytes[i] as number) & 0xc0) === 0x80) i++
|
||||
return bytes.subarray(i)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds a byte-oriented text stream, keeping a prefix, a suffix, or both
|
||||
* ({@link TextRetentionStrategy}). All three strategies share one prefix/suffix
|
||||
* accumulator: `head` is prefix-only, `tail` is suffix-only, `headTail` is both.
|
||||
*
|
||||
* Bytes, not characters: caps and `omittedBytes` are byte counts for process/
|
||||
* body safety. Chunks that straddle a codepoint are handled — {@link finish}
|
||||
* trims a partial codepoint at each cut so the returned text never introduces a
|
||||
* replacement char at the boundary. The retainer holds at most
|
||||
* `prefixCap + tailBytes + one chunk` in memory (old suffix chunks are dropped
|
||||
* as they slide out), so a large stream does not accumulate unbounded.
|
||||
*/
|
||||
export class TextRetainer {
|
||||
private readonly prefixCap: number
|
||||
private readonly suffixCap: number
|
||||
private readonly prefixChunks: Uint8Array[] = []
|
||||
private prefixHeld = 0
|
||||
private readonly suffixChunks: Uint8Array[] = []
|
||||
private suffixHeld = 0
|
||||
private total = 0
|
||||
|
||||
/** @param strategy One of the {@link TextRetentionStrategy} shapes; byte budgets must be non-negative integers. */
|
||||
constructor(strategy: TextRetentionStrategy) {
|
||||
switch (strategy.kind) {
|
||||
case 'head':
|
||||
assertBudget(strategy.maxBytes, 'maxBytes')
|
||||
this.prefixCap = strategy.maxBytes
|
||||
this.suffixCap = 0
|
||||
break
|
||||
case 'tail':
|
||||
assertBudget(strategy.maxBytes, 'maxBytes')
|
||||
this.prefixCap = 0
|
||||
this.suffixCap = strategy.maxBytes
|
||||
break
|
||||
case 'headTail':
|
||||
assertBudget(strategy.headBytes, 'headBytes')
|
||||
assertBudget(strategy.tailBytes, 'tailBytes')
|
||||
this.prefixCap = strategy.headBytes
|
||||
this.suffixCap = strategy.tailBytes
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer one chunk (a `Uint8Array`, or a `string` encoded as UTF-8). Prefix
|
||||
* bytes fill up to the prefix cap then stop; suffix bytes roll so only the
|
||||
* last `suffixCap` bytes are retained. `kept` is `true` only when no byte of
|
||||
* this chunk was dropped.
|
||||
*
|
||||
* @param chunk The next bytes of the stream (`Uint8Array` or UTF-8 `string`).
|
||||
* @returns The per-push {@link PushDecision}.
|
||||
*/
|
||||
push(chunk: Uint8Array | string): PushDecision {
|
||||
const bytes = typeof chunk === 'string' ? encoder.encode(chunk) : chunk
|
||||
const before = this.total
|
||||
this.total += bytes.length
|
||||
|
||||
// Prefix: take only up to the cap; the rest of this chunk is "not prefixed".
|
||||
const room = this.prefixCap - this.prefixHeld
|
||||
const take = Math.max(0, Math.min(room, bytes.length))
|
||||
if (take > 0) {
|
||||
this.prefixChunks.push(bytes.subarray(0, take))
|
||||
this.prefixHeld += take
|
||||
}
|
||||
|
||||
// Suffix: append the whole chunk, then drop whole leading chunks that have
|
||||
// fully slid out of the last `suffixCap` bytes (bounded memory).
|
||||
if (this.suffixCap > 0) {
|
||||
this.suffixChunks.push(bytes)
|
||||
this.suffixHeld += bytes.length
|
||||
let head = this.suffixChunks[0]
|
||||
while (head !== undefined && this.suffixHeld - head.length >= this.suffixCap) {
|
||||
this.suffixChunks.shift()
|
||||
this.suffixHeld -= head.length
|
||||
head = this.suffixChunks[0]
|
||||
}
|
||||
// The head chunk can still hold leading bytes beyond the last `suffixCap`
|
||||
// — a single chunk LARGER than the window is retained whole by the loop
|
||||
// above (dropping the only chunk would leave < cap). Trim those leading
|
||||
// bytes so the accumulator (and finish()'s concat) stays bounded by
|
||||
// `suffixCap` instead of allocating/copying the full chunk again;
|
||||
// finish() only ever reads the last `suffixLen ≤ suffixCap` bytes, so this
|
||||
// drops nothing it would return. (head.length > excess by the loop
|
||||
// invariant `suffixHeld - head.length < suffixCap`, so the slice is non-empty.)
|
||||
if (head !== undefined && this.suffixHeld > this.suffixCap) {
|
||||
const excess = this.suffixHeld - this.suffixCap
|
||||
this.suffixChunks[0] = head.subarray(excess)
|
||||
this.suffixHeld -= excess
|
||||
}
|
||||
}
|
||||
|
||||
// Dropped = bytes that no side can keep. Compute cumulative omission the
|
||||
// SAME way finish() does (via omittedAt), so push and finish never disagree;
|
||||
// per-push we only need whether THIS chunk pushed the total past what the
|
||||
// two caps hold.
|
||||
const droppedThisChunk = this.omittedAt(this.total) > this.omittedAt(before)
|
||||
return {
|
||||
kept: !droppedThisChunk,
|
||||
truncated: this.omittedAt(this.total) > 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Bytes omitted once `total` bytes have been seen: `total − keptPrefix − keptSuffix`. */
|
||||
private omittedAt(total: number): number {
|
||||
const prefixLen = Math.min(total, this.prefixCap)
|
||||
const suffixLen = Math.min(total - prefixLen, this.suffixCap)
|
||||
return total - prefixLen - suffixLen
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize: decode the retained prefix and suffix (each trimmed to a UTF-8
|
||||
* boundary at its cut) and report the exact omitted byte count.
|
||||
*
|
||||
* @returns The {@link RetainedText} snapshot (safe to hand to a formatter).
|
||||
*/
|
||||
finish(): RetainedText {
|
||||
const prefixLen = Math.min(this.total, this.prefixCap)
|
||||
const suffixLen = Math.min(this.total - prefixLen, this.suffixCap)
|
||||
|
||||
const prefix = concat(this.prefixChunks) // exactly prefixLen bytes (prefixHeld === prefixLen)
|
||||
const suffix = concat(this.suffixChunks).subarray(this.suffixHeld - suffixLen)
|
||||
|
||||
// With nothing omitted by budget, prefix and suffix are ADJACENT slices of
|
||||
// one stream (prefixLen + suffixLen === total), so the head|tail split is
|
||||
// artificial: a codepoint may span it. Decode the contiguous whole as one
|
||||
// buffer — trimming or decoding the halves separately here would corrupt a
|
||||
// boundary-spanning codepoint though no content was dropped. Only a real
|
||||
// omitted gap makes each side a true cut: trim each to a UTF-8 boundary and
|
||||
// decode separately so a codepoint is never reconstructed across the gap.
|
||||
const budgetOmitted = this.omittedAt(this.total)
|
||||
const [keptPrefix, keptSuffix] = budgetOmitted > 0
|
||||
? [trimTrailingPartialUtf8(prefix), trimLeadingContinuationUtf8(suffix)]
|
||||
: [prefix, suffix]
|
||||
const text = budgetOmitted > 0
|
||||
? decoder.decode(keptPrefix) + decoder.decode(keptSuffix)
|
||||
: decoder.decode(concat([prefix, suffix]))
|
||||
|
||||
// Report omission against the bytes ACTUALLY returned, not the pre-trim
|
||||
// budget: a boundary trim drops partial-codepoint bytes too, so an exact
|
||||
// count derived from the budget alone would overstate the retained text (and
|
||||
// any "Omitted N bytes" notice built from it would be a lie).
|
||||
const omitted = this.total - keptPrefix.length - keptSuffix.length
|
||||
const truncated = omitted > 0
|
||||
|
||||
return {
|
||||
text,
|
||||
truncated,
|
||||
omittedBytes: truncated
|
||||
? { kind: 'exact', count: omitted }
|
||||
: { kind: 'none' },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Concatenate chunks into one contiguous buffer (their exact total length). */
|
||||
function concat(chunks: readonly Uint8Array[]): Uint8Array {
|
||||
let length = 0
|
||||
for (const chunk of chunks) length += chunk.length
|
||||
const out = new Uint8Array(length)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized, false-precision-safe wording for one {@link Omitted} value —
|
||||
* the "may standardize omission wording" half the library owns. `exact` prints
|
||||
* the count (`Omitted 3 items`); `unknown` prints NO count because the caller
|
||||
* did not provide one. `none` is the empty string.
|
||||
*
|
||||
* @param omitted The omission metadata from a retainer result.
|
||||
* @param unit The noun for the omitted quantity (`items`, `bytes`, `chars`, `lines`).
|
||||
* @returns A neutral clause (no trailing space), or `''` when nothing was omitted.
|
||||
*/
|
||||
export function describeOmitted(omitted: Omitted, unit: RetentionNotice['unit']): string {
|
||||
switch (omitted.kind) {
|
||||
case 'none':
|
||||
return ''
|
||||
case 'exact':
|
||||
return `Omitted ${omitted.count} ${unit}.`
|
||||
case 'unknown':
|
||||
return `More ${unit} were omitted.`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a {@link RetentionNotice} into a one-line footer: the library-owned
|
||||
* standardized omission clause ({@link describeOmitted}) followed by the tool's
|
||||
* own recovery guidance. The library never owns recovery words — only the tool
|
||||
* knows the action ("narrow the pattern", "fetch a more specific URL", "read the
|
||||
* spill file") — so `recovery` supplies them and receives the full notice to
|
||||
* phrase from (`kept`, `limit`, `omitted`, …). Either half may be empty; the two
|
||||
* are joined with a single space.
|
||||
*
|
||||
* @param notice The neutral retention outcome.
|
||||
* @param recovery Tool-supplied guidance builder; receives the notice, returns a sentence (or `''`).
|
||||
* @returns The combined footer line.
|
||||
*/
|
||||
export function formatRetentionNotice(
|
||||
notice: RetentionNotice,
|
||||
recovery: (notice: RetentionNotice) => string,
|
||||
): string {
|
||||
return [describeOmitted(notice.omitted, notice.unit), recovery(notice)]
|
||||
.filter(part => part.length > 0)
|
||||
.join(' ')
|
||||
}
|
||||
376
packages/util/retention/tests/retention.spec.ts
Normal file
376
packages/util/retention/tests/retention.spec.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
describeOmitted,
|
||||
formatRetentionNotice,
|
||||
ItemRetainer,
|
||||
type Omitted,
|
||||
type RetentionNotice,
|
||||
TextRetainer,
|
||||
} from '@deepseek-ai/dsh-retention'
|
||||
|
||||
/** Decode a RetainedText via a round-trip helper for readable UTF-8 assertions. */
|
||||
const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s)
|
||||
|
||||
describe('ItemRetainer — head retention', () => {
|
||||
it('keeps the first maxItems while callers keep draining for an exact omitted count', () => {
|
||||
const r = new ItemRetainer<string>({ kind: 'head', maxItems: 2 })
|
||||
expect(r.push('a')).toEqual({ kept: true, truncated: false })
|
||||
expect(r.push('b')).toEqual({ kept: true, truncated: false })
|
||||
expect(r.push('c')).toEqual({ kept: false, truncated: true })
|
||||
|
||||
const result = r.finish()
|
||||
expect(result.items).toEqual(['a', 'b'])
|
||||
expect(result.kept).toBe(2)
|
||||
expect(result.seen).toBe(3)
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.omitted).toEqual<Omitted>({ kind: 'exact', count: 1 })
|
||||
})
|
||||
|
||||
it('reports none when everything fits', () => {
|
||||
const r = new ItemRetainer<number>({ kind: 'head', maxItems: 3 })
|
||||
r.push(1)
|
||||
r.push(2)
|
||||
const result = r.finish()
|
||||
expect(result.items).toEqual([1, 2])
|
||||
expect(result.truncated).toBe(false)
|
||||
expect(result.omitted).toEqual<Omitted>({ kind: 'none' })
|
||||
})
|
||||
it('keeps draining past the cap and reports an exact omitted count', () => {
|
||||
const r = new ItemRetainer<string>({ kind: 'head', maxItems: 1 })
|
||||
expect(r.push('a')).toEqual({ kept: true, truncated: false })
|
||||
expect(r.push('b')).toEqual({ kept: false, truncated: true })
|
||||
expect(r.push('c')).toEqual({ kept: false, truncated: true })
|
||||
|
||||
const result = r.finish()
|
||||
expect(result.items).toEqual(['a'])
|
||||
expect(result.seen).toBe(3)
|
||||
expect(result.omitted).toEqual<Omitted>({ kind: 'exact', count: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('ItemRetainer — zero budget', () => {
|
||||
it('keeps nothing and counts every pushed item as omitted', () => {
|
||||
const r = new ItemRetainer<string>({ kind: 'head', maxItems: 0 })
|
||||
expect(r.push('a')).toEqual({ kept: false, truncated: true })
|
||||
const result = r.finish()
|
||||
expect(result.items).toEqual([])
|
||||
expect(result.kept).toBe(0)
|
||||
expect(result.omitted).toEqual<Omitted>({ kind: 'exact', count: 1 })
|
||||
})
|
||||
|
||||
it('rejects a non-integer / negative maxItems', () => {
|
||||
expect(() => new ItemRetainer({ kind: 'head', maxItems: -1 }))
|
||||
.toThrow(/maxItems must be a non-negative integer/)
|
||||
expect(() => new ItemRetainer({ kind: 'head', maxItems: 1.5 }))
|
||||
.toThrow(/maxItems must be a non-negative integer/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TextRetainer — head (exact omission, reads to end)', () => {
|
||||
it('keeps the prefix and counts omitted bytes exactly', () => {
|
||||
const r = new TextRetainer({ kind: 'head', maxBytes: 5 })
|
||||
expect(r.push('abc')).toEqual({ kept: true, truncated: false })
|
||||
// 'de' fills the cap exactly (5 bytes) — still fully kept.
|
||||
expect(r.push('de')).toEqual({ kept: true, truncated: false })
|
||||
expect(r.push('fgh')).toEqual({ kept: false, truncated: true })
|
||||
|
||||
const result = r.finish()
|
||||
expect(result.text).toBe('abcde')
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 3 })
|
||||
})
|
||||
|
||||
it('flags a partially-dropped chunk as not fully kept', () => {
|
||||
const r = new TextRetainer({ kind: 'head', maxBytes: 4 })
|
||||
r.push('ab')
|
||||
// 'cde' straddles the cap: 'c','d' fit, 'e' drops → kept:false.
|
||||
expect(r.push('cde')).toEqual({ kept: false, truncated: true })
|
||||
expect(r.finish().text).toBe('abcd')
|
||||
})
|
||||
|
||||
it('keeps draining past the cap', () => {
|
||||
const r = new TextRetainer({ kind: 'head', maxBytes: 3 })
|
||||
r.push('abc')
|
||||
expect(r.push('defg')).toEqual({ kept: false, truncated: true })
|
||||
const result = r.finish()
|
||||
expect(result.text).toBe('abc')
|
||||
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 4 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('TextRetainer — tail (exact omission, reads to end)', () => {
|
||||
it('keeps the final maxBytes and reports exact omission', () => {
|
||||
const r = new TextRetainer({ kind: 'tail', maxBytes: 4 })
|
||||
expect(r.push('hello')).toEqual({ kept: false, truncated: true })
|
||||
r.push('world')
|
||||
const result = r.finish()
|
||||
expect(result.text).toBe('orld') // last 4 bytes of 'helloworld'
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 6 })
|
||||
})
|
||||
|
||||
it('keeps everything when the stream is under the cap', () => {
|
||||
const r = new TextRetainer({ kind: 'tail', maxBytes: 100 })
|
||||
r.push('short')
|
||||
const result = r.finish()
|
||||
expect(result.text).toBe('short')
|
||||
expect(result.truncated).toBe(false)
|
||||
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'none' })
|
||||
})
|
||||
|
||||
it('drops old chunks as they slide out of the tail window', () => {
|
||||
const r = new TextRetainer({ kind: 'tail', maxBytes: 3 })
|
||||
for (const c of ['11', '22', '33', '44']) r.push(c)
|
||||
// Only the final 3 bytes survive; earlier whole chunks are dropped.
|
||||
expect(r.finish().text).toBe('344')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TextRetainer — headTail (prefix + suffix, omit the middle)', () => {
|
||||
it('keeps a stable head and tail, omitting the middle exactly', () => {
|
||||
const r = new TextRetainer({ kind: 'headTail', headBytes: 3, tailBytes: 3 })
|
||||
r.push('abcdefghij') // 10 bytes: head 'abc', tail 'hij', middle 'defg' omitted
|
||||
const result = r.finish()
|
||||
expect(result.text).toBe('abchij')
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 4 })
|
||||
})
|
||||
|
||||
it('does not double-count when head+tail cover the whole stream', () => {
|
||||
const r = new TextRetainer({ kind: 'headTail', headBytes: 3, tailBytes: 3 })
|
||||
r.push('abcdef') // exactly head(3) + tail(3), nothing omitted
|
||||
const result = r.finish()
|
||||
expect(result.text).toBe('abcdef')
|
||||
expect(result.truncated).toBe(false)
|
||||
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'none' })
|
||||
})
|
||||
|
||||
it('does not drop a codepoint that spans the head|tail split when nothing is omitted', () => {
|
||||
// Regression: with head+tail covering the whole stream, the split is
|
||||
// artificial — a multibyte codepoint may straddle it. 'éab' is C3 A9 61 62
|
||||
// (4 bytes); headBytes 1 + tailBytes 3 covers all 4 with omitted === 0, but
|
||||
// the split falls INSIDE 'é'. The bytes are contiguous, so the full 'éab'
|
||||
// must survive — not be trimmed to 'ab'.
|
||||
const r = new TextRetainer({ kind: 'headTail', headBytes: 1, tailBytes: 3 })
|
||||
r.push('éab')
|
||||
const result = r.finish()
|
||||
expect(result.text).toBe('éab')
|
||||
expect(result.truncated).toBe(false)
|
||||
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'none' })
|
||||
})
|
||||
|
||||
it('still trims boundary partials once a real middle is omitted', () => {
|
||||
// With a genuine gap the two sides ARE true cuts: '€' (3 bytes) split across
|
||||
// the omitted middle must not resurface as a replacement char on either side.
|
||||
const r = new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 2 })
|
||||
r.push('a€€b') // 8 bytes; head 'a'+partial, tail partial+'b', middle omitted
|
||||
const result = r.finish()
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.text).not.toContain('<27>')
|
||||
expect(result.text.startsWith('a')).toBe(true)
|
||||
expect(result.text.endsWith('b')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TextRetainer — zero budgets', () => {
|
||||
it('head maxBytes 0 keeps nothing and counts every byte exactly', () => {
|
||||
const r = new TextRetainer({ kind: 'head', maxBytes: 0 })
|
||||
expect(r.push('x')).toEqual({ kept: false, truncated: true })
|
||||
const result = r.finish()
|
||||
expect(result.text).toBe('')
|
||||
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 1 })
|
||||
})
|
||||
|
||||
it('an empty stream omits nothing', () => {
|
||||
const r = new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 2 })
|
||||
const result = r.finish()
|
||||
expect(result.text).toBe('')
|
||||
expect(result.truncated).toBe(false)
|
||||
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'none' })
|
||||
})
|
||||
|
||||
it('rejects non-integer / negative byte budgets', () => {
|
||||
expect(() => new TextRetainer({ kind: 'head', maxBytes: -1 }))
|
||||
.toThrow(/maxBytes must be a non-negative integer/)
|
||||
expect(() => new TextRetainer({ kind: 'tail', maxBytes: 2.5 }))
|
||||
.toThrow(/maxBytes must be a non-negative integer/)
|
||||
expect(() => new TextRetainer({ kind: 'headTail', headBytes: -1, tailBytes: 2 }))
|
||||
.toThrow(/headBytes must be a non-negative integer/)
|
||||
expect(() => new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 1.1 }))
|
||||
.toThrow(/tailBytes must be a non-negative integer/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TextRetainer — UTF-8 boundary handling', () => {
|
||||
it('trims a partial codepoint at the head cut instead of emitting U+FFFD', () => {
|
||||
// '€' is 3 bytes (E2 82 AC). A 2-byte head cap keeps 'a' (61) + the first
|
||||
// byte of '€' (E2); that partial lead byte must be trimmed, not decoded to
|
||||
// a replacement char.
|
||||
const r = new TextRetainer({ kind: 'head', maxBytes: 2 })
|
||||
r.push('a€b') // bytes: 61 E2 82 AC 62
|
||||
const result = r.finish()
|
||||
expect(result.text).toBe('a') // partial '€' dropped, no U+FFFD
|
||||
expect(result.text).not.toContain('<27>')
|
||||
// Omission counts bytes ACTUALLY absent from the returned text, including
|
||||
// the partial 'E2' the boundary trim dropped: 5 total − 1 retained = 4
|
||||
// (not the pre-trim budget of 3, which would overstate what was kept).
|
||||
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 4 })
|
||||
})
|
||||
|
||||
it('trims a leading partial codepoint at the tail cut', () => {
|
||||
// Tail cap 2 over 'a€b' (5 bytes) keeps AC 62 — AC is a continuation byte
|
||||
// (the middle of '€'); the leading continuation byte is dropped so the tail
|
||||
// begins on a boundary.
|
||||
const r = new TextRetainer({ kind: 'tail', maxBytes: 2 })
|
||||
r.push('a€b')
|
||||
const result = r.finish()
|
||||
expect(result.text).toBe('b') // partial '€' at the front dropped
|
||||
expect(result.text).not.toContain('<27>')
|
||||
// Honest count: 5 total − 1 retained ('b') = 4, including the trimmed AC.
|
||||
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 4 })
|
||||
})
|
||||
|
||||
it('omitted count matches the bytes actually absent, across a headTail boundary trim', () => {
|
||||
// Regression: the exact count must equal total − retained (post-trim), never
|
||||
// the pre-trim budget. 'a€€b' is 8 bytes (61 E2828C… ×2 61? no: 61 E2 82 AC
|
||||
// E2 82 AC 62). headBytes 2 keeps 'a'+partial-E2 → trims to 'a' (1 byte);
|
||||
// tailBytes 2 keeps partial-AC+'b' → trims to 'b' (1 byte). Retained text is
|
||||
// 2 bytes, so omitted must be 8 − 2 = 6 — not the budget's 8 − 2 − 2 = 4.
|
||||
const r = new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 2 })
|
||||
r.push('a€€b')
|
||||
const result = r.finish()
|
||||
const retainedBytes = new TextEncoder().encode(result.text).length
|
||||
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 8 - retainedBytes })
|
||||
})
|
||||
|
||||
it('preserves a whole multibyte codepoint that fits exactly', () => {
|
||||
const r = new TextRetainer({ kind: 'head', maxBytes: 3 })
|
||||
r.push('€x') // '€' is exactly 3 bytes
|
||||
expect(r.finish().text).toBe('€')
|
||||
})
|
||||
|
||||
it('does not reconstruct a codepoint across the omitted middle', () => {
|
||||
// headBytes ends mid-'€' and tailBytes starts mid-another '€'; neither cut
|
||||
// may glue a valid codepoint across the gap.
|
||||
const r = new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 2 })
|
||||
r.push('€€€') // 9 bytes
|
||||
const result = r.finish()
|
||||
expect(result.text).not.toContain('<27>')
|
||||
expect(result.truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a raw Uint8Array chunk', () => {
|
||||
const r = new TextRetainer({ kind: 'head', maxBytes: 2 })
|
||||
r.push(utf8('xy'))
|
||||
r.push(utf8('z'))
|
||||
expect(r.finish().text).toBe('xy')
|
||||
})
|
||||
|
||||
it('trims a partial 2-byte codepoint at the head cut', () => {
|
||||
// 'é' is 2 bytes (C3 A9). A 2-byte head cap over 'aé' keeps 'a' (61) + the
|
||||
// lead byte of 'é' (C3) — an incomplete 2-byte sequence to trim.
|
||||
const r = new TextRetainer({ kind: 'head', maxBytes: 2 })
|
||||
r.push('aé') // bytes: 61 C3 A9
|
||||
const result = r.finish()
|
||||
expect(result.text).toBe('a')
|
||||
expect(result.text).not.toContain('<27>')
|
||||
})
|
||||
|
||||
it('trims a partial 4-byte codepoint (emoji) at the head cut', () => {
|
||||
// '😀' is 4 bytes (F0 9F 98 80). A 3-byte head cap keeps 'a' + the first two
|
||||
// bytes of the emoji — an incomplete 4-byte sequence that must be trimmed.
|
||||
const r = new TextRetainer({ kind: 'head', maxBytes: 3 })
|
||||
r.push('a😀') // bytes: 61 F0 9F 98 80
|
||||
const result = r.finish()
|
||||
expect(result.text).toBe('a')
|
||||
expect(result.text).not.toContain('<27>')
|
||||
})
|
||||
|
||||
it('keeps a whole 4-byte codepoint that fits exactly', () => {
|
||||
const r = new TextRetainer({ kind: 'head', maxBytes: 4 })
|
||||
r.push('😀x')
|
||||
expect(r.finish().text).toBe('😀')
|
||||
})
|
||||
|
||||
it('leaves a head cut ending on a stray continuation run untouched', () => {
|
||||
// A cut whose trailing bytes are ALL continuation bytes with no lead in
|
||||
// reach is not a trimmable incomplete sequence — the trimmer bails (no lead
|
||||
// byte found) and leaves them for the non-fatal decoder to replace.
|
||||
const r = new TextRetainer({ kind: 'head', maxBytes: 2 })
|
||||
// 0x80 0x80 are bare continuation bytes; 'z' follows so the head keeps just
|
||||
// the two continuation bytes and the cut lands right after them.
|
||||
r.push(new Uint8Array([0x80, 0x80, 0x7a]))
|
||||
const result = r.finish()
|
||||
// The trimmer did not throw and did not eat the bytes as a partial sequence;
|
||||
// only the trailing 'z' is omitted by the 2-byte cap.
|
||||
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 1 })
|
||||
})
|
||||
|
||||
it('leaves a head cut ending on an invalid lead byte untouched', () => {
|
||||
// 0xF8 is not a valid UTF-8 lead byte (only 0x00–0xF7 lead). The trimmer
|
||||
// recognizes it as "not a lead" (expected length 0) and leaves the byte in
|
||||
// place rather than trimming a phantom partial sequence.
|
||||
const r = new TextRetainer({ kind: 'head', maxBytes: 1 })
|
||||
r.push(new Uint8Array([0xf8, 0x61])) // 0xF8 kept, 'a' dropped by the 1-byte cap
|
||||
const result = r.finish()
|
||||
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeOmitted — false precision safety', () => {
|
||||
it('prints an exact count for exact omission', () => {
|
||||
expect(describeOmitted({ kind: 'exact', count: 3 }, 'items')).toBe('Omitted 3 items.')
|
||||
expect(describeOmitted({ kind: 'exact', count: 12 }, 'bytes')).toBe('Omitted 12 bytes.')
|
||||
})
|
||||
|
||||
it('prints NO count for unknown omission', () => {
|
||||
expect(describeOmitted({ kind: 'unknown' }, 'lines')).toBe('More lines were omitted.')
|
||||
})
|
||||
|
||||
it('returns empty string when nothing was omitted', () => {
|
||||
expect(describeOmitted({ kind: 'none' }, 'chars')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatRetentionNotice', () => {
|
||||
const notice = (omitted: Omitted): RetentionNotice => ({
|
||||
scope: 'grep',
|
||||
strategy: 'head',
|
||||
unit: 'items',
|
||||
limit: 100,
|
||||
kept: 100,
|
||||
omitted,
|
||||
})
|
||||
|
||||
it('joins the standardized omission clause with the tool recovery guidance', () => {
|
||||
const out = formatRetentionNotice(
|
||||
notice({ kind: 'exact', count: 25 }),
|
||||
({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`,
|
||||
)
|
||||
expect(out).toBe('Omitted 25 items. Results capped at 100. Narrow the pattern, path, or include to see more.')
|
||||
})
|
||||
|
||||
it('omits the empty half when nothing was omitted', () => {
|
||||
const out = formatRetentionNotice(notice({ kind: 'none' }), () => 'Recovery text.')
|
||||
expect(out).toBe('Recovery text.')
|
||||
})
|
||||
|
||||
it('omits the empty half when the tool supplies no recovery text', () => {
|
||||
const out = formatRetentionNotice(notice({ kind: 'exact', count: 2 }), () => '')
|
||||
expect(out).toBe('Omitted 2 items.')
|
||||
})
|
||||
|
||||
it('passes the full notice to the recovery builder (limit as a head/tail pair)', () => {
|
||||
const headTail: RetentionNotice = {
|
||||
scope: 'bash stdout',
|
||||
strategy: 'headTail',
|
||||
unit: 'bytes',
|
||||
limit: { head: 2_000, tail: 2_000 },
|
||||
kept: 4_000,
|
||||
omitted: { kind: 'exact', count: 500 },
|
||||
}
|
||||
const out = formatRetentionNotice(headTail, n =>
|
||||
typeof n.limit === 'object' ? `Kept ${n.limit.head}B head + ${n.limit.tail}B tail.` : '')
|
||||
expect(out).toBe('Omitted 500 bytes. Kept 2000B head + 2000B tail.')
|
||||
})
|
||||
})
|
||||
11
packages/util/retention/tsconfig.json
Normal file
11
packages/util/retention/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
@@ -25,12 +25,19 @@ import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/d
|
||||
|
||||
## Usage shape
|
||||
|
||||
```ts ignore-check
|
||||
```ts
|
||||
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
declare function runWork(options: { signal: AbortSignal }): Promise<unknown>
|
||||
|
||||
// Scope-lifetime consumer (foreground bash, one fetch): `using` disposes the timer.
|
||||
using d = deadline(upstream, timeoutMs, 'BASH_TIMEOUT')
|
||||
const outcome = await runWork({ signal: d.signal }) // work listens on d.signal and terminates itself
|
||||
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined // classify the first abort, scoped to OUR code
|
||||
const aborted = d.signal.aborted && !timedOut // mutually exclusive: timeout won, or cancel did
|
||||
export async function runWithDeadline(upstream: AbortSignal | undefined, timeoutMs: number): Promise<unknown> {
|
||||
using d = deadline(upstream, timeoutMs, 'BASH_TIMEOUT')
|
||||
const outcome = await runWork({ signal: d.signal }) // work listens on d.signal and terminates itself
|
||||
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined // classify the first abort, scoped to OUR code
|
||||
const aborted = d.signal.aborted && !timedOut // mutually exclusive: timeout won, or cancel did
|
||||
return { outcome, timedOut, aborted }
|
||||
}
|
||||
```
|
||||
|
||||
The signal only *notifies* — the caller MUST attach its own termination (`d.signal.addEventListener('abort', kill)`, or hand `d.signal` to `fetch`). Racing a promise against a timer would resolve the tool-call while the child process or socket leaks on; handing out a signal forces a real termination path to exist.
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
|
||||
|
||||
95
packages/web/tool-web/tests/spill.spec.ts
Normal file
95
packages/web/tool-web/tests/spill.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Showcase integration: the real `web_fetch` tool + the real spill stack
|
||||
* (`dsh-spill-local` backend + `dsh-spill-policy`), exercised through
|
||||
* `ctx.tools.execute()`. Proves the RFC's default local-backend path — a large
|
||||
* formatted fetch result is automatically retained and spilled with NO
|
||||
* tool-specific spill code, and the model-facing text changes ONLY by the
|
||||
* deliberate spill notice (the full formatted result lands in the spill file).
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
||||
import { AddressInfo } from 'node:net'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import WebService from '@deepseek-ai/dsh-web'
|
||||
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
|
||||
import LocalSpillStore from '@deepseek-ai/dsh-spill-local'
|
||||
import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
|
||||
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
|
||||
|
||||
type Handler = (req: IncomingMessage, res: ServerResponse) => void
|
||||
|
||||
let server: Server
|
||||
let base: string
|
||||
let handler: Handler
|
||||
let spillRoot: string
|
||||
let ctx: Context
|
||||
|
||||
const BODY = 'X'.repeat(4000) // formatted result is well over the policy cap
|
||||
const MAX_INLINE_BYTES = 1000 // leaves room for a head/tail preview beside the notice
|
||||
|
||||
beforeEach(async () => {
|
||||
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end(BODY) }
|
||||
server = createServer((req, res) => { handler(req, res) })
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
|
||||
spillRoot = mkdtempSync(join(tmpdir(), 'dsh-spill-web-'))
|
||||
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(WebService, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
|
||||
// Provider cap generous so the tool returns a large formatted result; the
|
||||
// policy cap is what triggers the spill (the RFC's separation of concerns).
|
||||
await ctx.plugin(WebFetchLocal, { maxBodyChars: 500_000 })
|
||||
await ctx.plugin(LocalSpillStore, { root: spillRoot })
|
||||
await ctx.plugin(SpillPolicy, { maxInlineBytes: MAX_INLINE_BYTES })
|
||||
await ctx.plugin(ToolWeb)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>(resolve => server.close(() => { resolve() }))
|
||||
rmSync(spillRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A web_fetch call carrying a session owner (so the policy can scope the spill). */
|
||||
function fetchCall(): Promise<{ isError: boolean; content: { type: string; text?: string }[] }> {
|
||||
const agent = { session: { header: { id: SessionId('web-sess') } } }
|
||||
const exec = { callId: CallId('call-1'), name: 'web_fetch', arguments: { url: base }, agent } as unknown as ToolExecution
|
||||
return ctx.tools.execute(exec)
|
||||
}
|
||||
|
||||
describe('web_fetch spill showcase', () => {
|
||||
it('spills a large formatted result and returns a preview + spill locator', async () => {
|
||||
const out = await fetchCall()
|
||||
expect(out.isError).toBe(false)
|
||||
const text = out.content.map(b => b.text).join('')
|
||||
|
||||
// Model-facing text is a preview + notice within the cap, NOT the full body.
|
||||
expect(text.length).toBeLessThan(BODY.length)
|
||||
expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(MAX_INLINE_BYTES)
|
||||
expect(text).toContain(`Fetched ${base}`) // the head of the formatted result survives
|
||||
expect(text).toContain('Full formatted result stored at:')
|
||||
expect(text).toContain('Use read with offset/limit, or grep this path')
|
||||
|
||||
// The spill file holds the FULL formatted result the tool returned.
|
||||
const match = /stored at: (\S+?)\. Use read/.exec(text)
|
||||
expect(match).not.toBeNull()
|
||||
const spillPath = match![1]!
|
||||
const saved = readFileSync(spillPath, 'utf8')
|
||||
// The provider cap was generous, so the tool did not truncate: the spill file
|
||||
// holds the full formatted result (header + the complete body), far larger
|
||||
// than the model-facing preview.
|
||||
expect(saved).toContain('(HTTP 200)')
|
||||
expect(saved).toContain(BODY)
|
||||
expect(saved.length).toBeGreaterThan(text.length)
|
||||
})
|
||||
})
|
||||
133
pnpm-lock.yaml
generated
133
pnpm-lock.yaml
generated
@@ -734,6 +734,43 @@ importers:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/fs/tool-fs-search:
|
||||
dependencies:
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-bash':
|
||||
specifier: workspace:^
|
||||
version: link:../../bash/bash
|
||||
'@deepseek-ai/dsh-bash-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../bash/bash-local
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-retention':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/retention
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-spill':
|
||||
specifier: workspace:^
|
||||
version: link:../../spill/spill
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/guard/repeat-tool-guard:
|
||||
dependencies:
|
||||
schemastery:
|
||||
@@ -877,7 +914,7 @@ importers:
|
||||
dependencies:
|
||||
'@earendil-works/pi-ai':
|
||||
specifier: ^0.79.1
|
||||
version: 0.79.3(ws@8.21.0)(zod@4.4.3)
|
||||
version: 0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
@@ -1149,6 +1186,71 @@ importers:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/spill/spill:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/spill/spill-local:
|
||||
dependencies:
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-spill':
|
||||
specifier: workspace:^
|
||||
version: link:../spill
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/spill/spill-policy:
|
||||
dependencies:
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-retention':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/retention
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-spill':
|
||||
specifier: workspace:^
|
||||
version: link:../spill
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/subagent/subagent:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
@@ -1783,6 +1885,12 @@ importers:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/util/retention:
|
||||
devDependencies:
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/util/timeout:
|
||||
devDependencies:
|
||||
cordis:
|
||||
@@ -1804,6 +1912,12 @@ importers:
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-spill-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../spill/spill-local
|
||||
'@deepseek-ai/dsh-spill-policy':
|
||||
specifier: workspace:^
|
||||
version: link:../../spill/spill-policy
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
@@ -3129,6 +3243,10 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2':
|
||||
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
||||
|
||||
@@ -6207,11 +6325,11 @@ snapshots:
|
||||
|
||||
'@csstools/css-tokenizer@4.0.0': {}
|
||||
|
||||
'@earendil-works/pi-ai@0.79.3(ws@8.21.0)(zod@4.4.3)':
|
||||
'@earendil-works/pi-ai@0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.91.1(zod@4.4.3)
|
||||
'@aws-sdk/client-bedrock-runtime': 3.1048.0
|
||||
'@google/genai': 1.52.0
|
||||
'@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))
|
||||
'@mistralai/mistralai': 2.2.1
|
||||
'@smithy/node-http-handler': 4.7.3
|
||||
http-proxy-agent: 7.0.2
|
||||
@@ -6369,12 +6487,14 @@ snapshots:
|
||||
|
||||
'@exodus/bytes@1.15.1': {}
|
||||
|
||||
'@google/genai@1.52.0':
|
||||
'@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))':
|
||||
dependencies:
|
||||
google-auth-library: 10.7.0
|
||||
p-retry: 4.6.2
|
||||
protobufjs: 7.6.4
|
||||
ws: 8.21.0
|
||||
optionalDependencies:
|
||||
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
@@ -6633,6 +6753,9 @@ snapshots:
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.20.0':
|
||||
optional: true
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@protobufjs/aspromise@1.1.2': {}
|
||||
|
||||
'@protobufjs/base64@1.1.2': {}
|
||||
@@ -8104,6 +8227,8 @@ snapshots:
|
||||
jackspeak@3.4.3:
|
||||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
optionalDependencies:
|
||||
'@pkgjs/parseargs': 0.11.0
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ const GROUP_ORDER = [
|
||||
'tasks',
|
||||
'workflow',
|
||||
'web',
|
||||
'spill',
|
||||
'todo',
|
||||
'cordis',
|
||||
'hooks',
|
||||
@@ -250,6 +251,15 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['tool-web'],
|
||||
note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.',
|
||||
},
|
||||
{
|
||||
key: 'spillStore',
|
||||
pkg: 'spill',
|
||||
title: 'Spill storage seam',
|
||||
mode: 'seam',
|
||||
implementations: ['spill-local'],
|
||||
consumers: ['spill-policy'],
|
||||
note: '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.',
|
||||
},
|
||||
{
|
||||
key: 'workflows',
|
||||
pkg: 'workflow',
|
||||
|
||||
@@ -27,6 +27,7 @@ const GROUP_ORDER = [
|
||||
'compact',
|
||||
'subagent',
|
||||
'web',
|
||||
'spill',
|
||||
'timeout',
|
||||
'todo',
|
||||
'cordis',
|
||||
|
||||
@@ -27,6 +27,7 @@ import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
@@ -149,6 +150,23 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
note:
|
||||
'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.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-fs-search',
|
||||
dir: 'tool-fs-search',
|
||||
source: 'packages/fs/tool-fs-search/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The tools inject `bash` (search executes fixed `rg` commands through
|
||||
// the executor seam, not ctx.fs); boot the local executor to satisfy it.
|
||||
// `ctx.spillStore` is optional (read via ctx.get) and does not affect the
|
||||
// schemas, so no spill backend is mounted.
|
||||
await ctx.plugin(LocalBashExecutor)
|
||||
await ctx.plugin(ToolFsSearch)
|
||||
},
|
||||
note:
|
||||
'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.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-skill',
|
||||
dir: 'tool-skill',
|
||||
|
||||
@@ -153,6 +153,12 @@
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SaveTextSpill", "source": "packages/spill/spill/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SpillOwner", "source": "packages/spill/spill/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SpillSource", "source": "packages/spill/spill/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SpillRef", "source": "packages/spill/spill/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SpillLocator", "source": "packages/spill/spill/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowStartRequest", "source": "packages/workflow/workflow/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowResult", "source": "packages/workflow/workflow/src/types.ts" },
|
||||
|
||||
@@ -55,6 +55,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
|
||||
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' },
|
||||
'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' },
|
||||
'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates parent-model rendering to dsh-tool-subagent.' },
|
||||
'packages/subagent/subagent-subprocess': { kind: 'indirect', reason: 'Only process-based subagent backends compose a child model request.' },
|
||||
'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' },
|
||||
@@ -69,6 +71,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/ui/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' },
|
||||
'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' },
|
||||
'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' },
|
||||
'packages/util/retention': { kind: 'indirect', reason: 'Only retention consumers render retained content and omission metadata.' },
|
||||
'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' },
|
||||
'packages/web/web-fetch-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
|
||||
'packages/web/web-search-exa': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"./packages/tasks/*/src",
|
||||
"./packages/workflow/*/src",
|
||||
"./packages/web/*/src",
|
||||
"./packages/spill/*/src",
|
||||
"./packages/timeout/*/src",
|
||||
"./packages/todo/*/src",
|
||||
"./packages/cordis/*/src",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
{ "path": "./packages/util/brand" },
|
||||
{ "path": "./packages/util/paths" },
|
||||
{ "path": "./packages/util/timeout" },
|
||||
{ "path": "./packages/util/retention" },
|
||||
{ "path": "./packages/llm/llm" },
|
||||
{ "path": "./packages/core/session" },
|
||||
{ "path": "./packages/core/scope" },
|
||||
@@ -50,12 +51,16 @@
|
||||
{ "path": "./packages/fs/fs-local" },
|
||||
{ "path": "./packages/fs/fs-policy" },
|
||||
{ "path": "./packages/fs/tool-fs" },
|
||||
{ "path": "./packages/fs/tool-fs-search" },
|
||||
{ "path": "./packages/web/web" },
|
||||
{ "path": "./packages/web/web-search-exa" },
|
||||
{ "path": "./packages/web/web-search-perplexity" },
|
||||
{ "path": "./packages/web/web-search-deepseek" },
|
||||
{ "path": "./packages/web/web-fetch-local" },
|
||||
{ "path": "./packages/web/tool-web" },
|
||||
{ "path": "./packages/spill/spill" },
|
||||
{ "path": "./packages/spill/spill-local" },
|
||||
{ "path": "./packages/spill/spill-policy" },
|
||||
{ "path": "./packages/timeout/timeout-policy" },
|
||||
{ "path": "./packages/support/invariants" },
|
||||
{ "path": "./packages/ui/acp" },
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
{ "path": "./packages/util/brand" },
|
||||
{ "path": "./packages/util/paths" },
|
||||
{ "path": "./packages/util/timeout" },
|
||||
{ "path": "./packages/util/retention" },
|
||||
{ "path": "./packages/llm/llm" },
|
||||
{ "path": "./packages/core/session" },
|
||||
{ "path": "./packages/core/scope" },
|
||||
@@ -59,6 +60,7 @@
|
||||
{ "path": "./packages/fs/fs-local" },
|
||||
{ "path": "./packages/fs/fs-policy" },
|
||||
{ "path": "./packages/fs/tool-fs" },
|
||||
{ "path": "./packages/fs/tool-fs-search" },
|
||||
{ "path": "./packages/compact/compact" },
|
||||
{ "path": "./packages/compact/compact-basic" },
|
||||
{ "path": "./packages/web/web" },
|
||||
@@ -67,6 +69,9 @@
|
||||
{ "path": "./packages/web/web-search-deepseek" },
|
||||
{ "path": "./packages/web/web-fetch-local" },
|
||||
{ "path": "./packages/web/tool-web" },
|
||||
{ "path": "./packages/spill/spill" },
|
||||
{ "path": "./packages/spill/spill-local" },
|
||||
{ "path": "./packages/spill/spill-policy" },
|
||||
{ "path": "./packages/timeout/timeout-policy" },
|
||||
{ "path": "./packages/support/invariants" },
|
||||
{ "path": "./packages/ui/acp" },
|
||||
|
||||
Reference in New Issue
Block a user