Merge origin/master into skill system branch
Use the new filesystem seam for skill file reads and system skill writes when ctx.fs is available, and include the skill tool in the generated tool catalog.
This commit is contained in:
@@ -11,7 +11,8 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam (backend + tool deferred) | 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 |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
@@ -30,12 +31,17 @@ dsh-bash ← dsh-brand (abstract executor seam; b
|
||||
dsh-session ← dsh-llm, dsh-brand
|
||||
dsh-system-prompt ← dsh-llm
|
||||
dsh-agent ← dsh-llm, dsh-session, dsh-brand
|
||||
dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; backend + tool deferred)
|
||||
dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; tool deferred)
|
||||
dsh-compact-basic ← dsh-compact, dsh-session, dsh-llm, dsh-agent (char/4 + token-budget retention backend)
|
||||
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
|
||||
dsh-skill ← dsh-llm, dsh-agent
|
||||
dsh-skill ← dsh-fs, dsh-llm, dsh-agent
|
||||
dsh-tool-skill ← dsh-skill, dsh-tools, dsh-agent, dsh-llm
|
||||
dsh-bash-local ← dsh-bash (BashExecutor impl)
|
||||
dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas)
|
||||
dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam + fs/* events)
|
||||
dsh-fs-local ← dsh-fs (FileSystem impl)
|
||||
dsh-fs-policy ← dsh-fs (observed-state + freshness policy gate, no service)
|
||||
dsh-tool-fs ← dsh-fs, dsh-tools (file tools + executor)
|
||||
dsh-llm-deepseek ← dsh-llm (DeepSeek adapter)
|
||||
dsh-llm-pi-ai ← dsh-llm (pi-ai-backed adapter)
|
||||
dsh-agent-loop ← dsh-llm, dsh-session, dsh-session-persistence, dsh-system-prompt, dsh-tools, dsh-agent
|
||||
@@ -74,7 +80,12 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
|
||||
| `bash/` | `bash` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` |
|
||||
| `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
|
||||
| `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
|
||||
| `fs/` | `fs` | Filesystem provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` events | `ctx.fs` |
|
||||
| `fs-local/` | `fs` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
|
||||
| `fs-policy/` | `fs` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit via the `fs/*` event gate | (no service — `fs/*` listeners) |
|
||||
| `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tools + executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
|
||||
| `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` |
|
||||
| `compact-basic/` | `compact` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
|
||||
| `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
|
||||
| `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) |
|
||||
| `session-persistence/` | `session-persistence` | Persistence seam + write coordinator | `ctx.sessionPersistence` |
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# compact/ — compaction capability family
|
||||
|
||||
A three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract compaction interface, a backend that summarizes, and the model-facing tool that consumes it. Only the interface tier exists today; the backend and consumer are deferred. All **product** packages.
|
||||
A three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract compaction interface, a backend that summarizes, and the model-facing tool that consumes it. The interface and a first backend (`compact-basic/`) exist; the consumer tool is deferred. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` |
|
||||
| `compact-basic/` (deferred) | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
|
||||
| `compact-basic/` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
|
||||
| `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `compact/compact/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool.
|
||||
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool.
|
||||
|
||||
57
packages/compact/compact-basic/README.md
Normal file
57
packages/compact/compact-basic/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# @deepseek-ai/dsh-compact-basic
|
||||
|
||||
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a char/4 token heuristic, token-budget retention, and summarization routed through the agent request pipeline.
|
||||
|
||||
This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design.
|
||||
|
||||
## What it owns
|
||||
|
||||
The abstract contract states only WHAT compaction does; this backend owns every HOW decision:
|
||||
|
||||
- **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length).
|
||||
- **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check.
|
||||
- **Dynamic convergence** — no static summary-length config pretends to bound what the model will write. If framing/estimator/system overhead leaves the compacted surface above threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times; if it still cannot get below threshold, it throws. A summary whose estimated stored size is not smaller than the shadowed content fails closed before it mutates the surface.
|
||||
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it.
|
||||
- **Checkpoint framing** — the raw summary is not landed directly. `compactRegion()` wraps it in a checkpoint preamble (so a resuming model reads it as a checkpoint, not a fresh user request, and builds on the captured context rather than restating it) plus `<compacted-summary>…</compacted-summary>` tags. Because region compaction can be invoked manually, a surface may hold several checkpoints, so the framing does not claim everything after it is recent or verbatim. The tags make a prior checkpoint detectable in the transcript on the next compaction cycle: the summarization prompt then instructs the model to merge it in place (preserve still-true facts, drop stale ones) rather than re-summarize it verbatim — a cheap incremental merge that needs no extra log/event machinery. The unframed summary stays on the `compact/summary` provenance event.
|
||||
- **Surface mutation** — `compactRegion()` appends the `compact/start` → `compact/summary` → `compact/end` log records and the single `user/message` replace node carrying the framed summary (see the interface README).
|
||||
- **Auto-compaction** — an `agent/pre-step` listener delegates to `compactIfNeeded()` before every step (not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts, and per-step firing is the only moment to rescue it before overflow). `agent/pre-step` is a serial (awaited, in-order) surface-mutation checkpoint that fires after `turn/start` and BEFORE the step opens (`step/start`) and its request history is derived, so compaction mutates the surface — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives once from the result: no double-derive, and the listener cannot see (or need to rewrite) an already-assembled `messages` array. The listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`); because Cordis `serial` bails early on non-void return values, the listener returns `void` and does not use the dispatcher's bail channel as a veto surface.
|
||||
- **Failure handling** — the `compact/start … compact/end` bracket is a log-recorded lock: it makes a crash mid-summarization a detectable orphan (a `compact/start` with no `compact/end`), records provenance, and prevents a concurrent compaction. Two failure paths: a **crash** (the loop dies mid-summarization) leaves a dangling `compact/start` that is inert — `compact/*` events are log-only, the surface replacement never landed, so the full history derives fine and generic turn-repair closes the turn; a **recoverable** failure (summarization throws but the loop survives) appends `compact/end` with its `error` field set, leaving the surface untouched so the call proceeds with full history. Core session repair stays compaction-agnostic by design — it never learns about `compact/*`.
|
||||
|
||||
`estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing.
|
||||
|
||||
## Config (`BasicCompactConfig`)
|
||||
|
||||
Every knob is **required** except `auto` — there is no concrete data yet to justify default thresholds/budgets, so a consumer states each value explicitly rather than inherit a guessed default. `auto` alone defaults to `true`.
|
||||
|
||||
| Key | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `contextWindow` | yes | Context window size in tokens. |
|
||||
| `thresholdRatio` | yes | Compact when estimated usage exceeds this fraction of the window. |
|
||||
| `retainTokens` | yes | Tokens of recent context to keep intact. |
|
||||
| `summarizationModel` | yes | Model for summarization (`''` → use the agent's model). |
|
||||
| `maxTokens` | yes | Provider generation cap for the summarization call; may include reasoning tokens. |
|
||||
| `compactionRetries` | yes | Extra compaction attempts after the first if the compacted surface remains over threshold. |
|
||||
| `auto` | no (default `true`) | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. |
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
|
||||
export const name = 'compact-basic'
|
||||
export const inject = ['llm']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.plugin(BasicCompactService, {
|
||||
contextWindow: 128000,
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 20480,
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly.
|
||||
42
packages/compact/compact-basic/package.json
Normal file
42
packages/compact/compact-basic/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-compact-basic",
|
||||
"description": "Basic compaction backend (char/4 token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness",
|
||||
"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-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-compact": "^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-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
746
packages/compact/compact-basic/src/index.ts
Normal file
746
packages/compact/compact-basic/src/index.ts
Normal file
@@ -0,0 +1,746 @@
|
||||
/**
|
||||
* `BasicCompactService`: the first implementation of the
|
||||
* `@deepseek-ai/dsh-compact` seam. It owns the entire compaction strategy:
|
||||
*
|
||||
* - **Token estimation** — char/4 heuristic with per-block structural overhead.
|
||||
* - **Retention policy** — walk surface nodes tail→head, keep recent nodes up
|
||||
* to a token budget, compact everything older. The cutoff is snapped forward
|
||||
* to the next balanced tool-pairing boundary so a compacted region never
|
||||
* splits a step's tool-call/result pair (an open tail step is never crossed —
|
||||
* compaction declines and retries once it closes).
|
||||
* - **Summarization** — `ctx.llm.stream()` assembled via `BlockAssembler`
|
||||
* (the single model-call surface; same path the loop uses) with a fixed
|
||||
* condense-the-history system prompt routed through `agent/request`.
|
||||
* - **Surface mutation** — a single `user/message` replace node carries the
|
||||
* summary; `compact/*` events are log-only lock + provenance records.
|
||||
* - **Auto-compaction** — an `agent/pre-step` listener delegates to
|
||||
* {@link BasicCompactService.compactIfNeeded} before EVERY step (so a
|
||||
* tool-heavy turn that grows the surface mid-turn still compacts); it owns the
|
||||
* sole token-pressure check.
|
||||
*
|
||||
* A different backend (real tokenizer, template summarizer, turn-count
|
||||
* retention) either subclasses this and overrides the {@link
|
||||
* BasicCompactService.estimateContentTokens} / {@link
|
||||
* BasicCompactService.summarize} hooks, or implements the abstract
|
||||
* {@link CompactService} from scratch.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-basic
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { CompactService } from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
|
||||
import { resolveConfig } from './types.ts'
|
||||
|
||||
export type { BasicCompactConfig, ResolvedConfig } from './types.ts'
|
||||
export { resolveConfig } from './types.ts'
|
||||
|
||||
/** Per-block structural overhead for JSON framing / type tag. */
|
||||
const BLOCK_OVERHEAD = 4
|
||||
|
||||
/** Heuristic token count for an image block (~85 tokens for low-res URL). */
|
||||
const IMAGE_TOKEN_COST = 85
|
||||
|
||||
/** Role-field framing overhead added per message in {@link BasicCompactService.estimateTokens}. */
|
||||
const ROLE_OVERHEAD = 4
|
||||
|
||||
/** Tags wrapping the structured summary inside the landed checkpoint node. */
|
||||
const SUMMARY_OPEN_TAG = '<compacted-summary>'
|
||||
const SUMMARY_CLOSE_TAG = '</compacted-summary>'
|
||||
|
||||
/**
|
||||
* The summarization system prompt: instructs the model to condense the
|
||||
* conversation into a fixed, fully-populated structure rather than freeform
|
||||
* bullets. The fixed structure guarantees coverage of the things a resuming
|
||||
* model needs (original intent, pending work, the next step, critical context)
|
||||
* and is stable across compaction cycles, so a prior checkpoint can be merged
|
||||
* in place. The final rule keys off {@link SUMMARY_OPEN_TAG}: when the
|
||||
* transcript already contains a prior checkpoint, the model consolidates rather
|
||||
* than re-summarizing it verbatim (a cheap incremental-merge that needs no
|
||||
* extra log/event machinery — the tag travels on the summary surface node).
|
||||
*/
|
||||
const SUMMARIZE_SYSTEM_PROMPT = [
|
||||
'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.',
|
||||
'',
|
||||
'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.',
|
||||
'',
|
||||
'## Primary Request and Intent',
|
||||
"- [the user's original and evolving goals; quote verbatim where the exact wording matters]",
|
||||
'',
|
||||
'## Key Technical Concepts',
|
||||
'- [technologies, frameworks, patterns, and conventions in play]',
|
||||
'',
|
||||
'## Files and Code',
|
||||
'- [exact path: why it matters, key changes or snippets]',
|
||||
'',
|
||||
'## Errors and Fixes',
|
||||
'- [error: how it was resolved, plus any related user feedback]',
|
||||
'',
|
||||
'## Pending Tasks',
|
||||
'- [explicitly requested work not yet completed]',
|
||||
'',
|
||||
'## Current Work',
|
||||
'- [precisely what was in progress at this checkpoint]',
|
||||
'',
|
||||
'## Next Step',
|
||||
'- [the single next action, directly in line with the most recent request, or "(none)"]',
|
||||
'',
|
||||
'## Critical Context',
|
||||
'- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
|
||||
'',
|
||||
'Rules:',
|
||||
'- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
|
||||
'- Capture user feedback and explicit instructions faithfully, especially corrections.',
|
||||
'- Do NOT mention this summarization process or that the context was compacted.',
|
||||
`- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
|
||||
].join('\n')
|
||||
|
||||
/**
|
||||
* Framing prepended to the landed summary so a resuming model reads it as a
|
||||
* checkpoint rather than a fresh user request, and continues the task from it.
|
||||
* It summarizes an earlier span of the conversation; the messages that follow
|
||||
* are the continuation. Because region compaction can be invoked manually, a
|
||||
* surface may hold several checkpoints, so the framing does NOT claim that
|
||||
* everything after it is recent or verbatim — only that the captured context
|
||||
* should be built on, not restated.
|
||||
*/
|
||||
const CHECKPOINT_PREAMBLE =
|
||||
'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
|
||||
|
||||
/**
|
||||
* Map a terminal `FinishReason` to the error a SUMMARIZATION must throw, or
|
||||
* `undefined` for an acceptable finish. `FinishReason` is merge-extensible.
|
||||
*
|
||||
* Compaction fails CLOSED on a truncated summary: `error`, `aborted`, AND
|
||||
* `max-tokens` all raise. Unlike an ordinary agent turn — where `max-tokens` is
|
||||
* a normal "the model hit its budget" outcome the loop keeps — a summary cut off
|
||||
* at the token cap is an INCOMPLETE checkpoint, and committing it would shadow
|
||||
* (discard) the real history it summarizes. Raising here keeps the original
|
||||
* surface intact (the caller appends `compact/end` with the error and the auto
|
||||
* path proceeds with full history). `stop`/future kinds are accepted.
|
||||
*/
|
||||
function finishError(finish: FinishReason): Error | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'error': {
|
||||
const error = new Error(finish.message) as Error & { code?: string }
|
||||
if (finish.code !== undefined) error.code = finish.code
|
||||
return error
|
||||
}
|
||||
case 'aborted': {
|
||||
const error = new Error('summarization stream aborted') as Error & { code?: string }
|
||||
error.code = 'ABORTED'
|
||||
return error
|
||||
}
|
||||
case 'max-tokens': {
|
||||
const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string }
|
||||
error.code = 'MAX_TOKENS'
|
||||
return error
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic, dependency-light compaction backend. Defaults target a 128K context
|
||||
* window, compacting at 80% utilization and retaining ~20K tokens of recent
|
||||
* context.
|
||||
*/
|
||||
export class BasicCompactService extends CompactService {
|
||||
static inject = ['llm']
|
||||
|
||||
/** Resolved configuration (`auto` defaulted). */
|
||||
readonly config: ResolvedConfig
|
||||
|
||||
constructor(ctx: Context, config: BasicCompactConfig) {
|
||||
super(ctx)
|
||||
this.config = resolveConfig(config)
|
||||
|
||||
if (this.config.auto) {
|
||||
// Auto-compaction: delegate to compactIfNeeded before EVERY step. This is
|
||||
// LOAD-BEARING for runaway-turn survival: a tool-heavy ReAct turn appends
|
||||
// an assistant/message and a tool/result per step, so the surface (and the
|
||||
// derived token count) grows WITHIN a turn. The only moment to rescue a
|
||||
// turn that alone approaches the window is the next step's pre-step
|
||||
// checkpoint; gating to a turn's first step would let a runaway turn
|
||||
// overflow before the next turn's check. The listener owns NO threshold
|
||||
// logic — compactIfNeeded is the single place that decides whether to
|
||||
// compact, and its in-progress lock serializes concurrent attempts.
|
||||
//
|
||||
// It runs on `agent/pre-step` (a serial surface-mutation checkpoint fired
|
||||
// AFTER turn/start but BEFORE step/start), NOT `agent/request`: compaction
|
||||
// mutates the session surface, and the loop derives the request `messages`
|
||||
// AFTER this fires — so a single derive already reflects the compaction,
|
||||
// with no double-derive and no need to rewrite an already-assembled
|
||||
// `messages` array. Firing pre-step (outside any open step) keeps the
|
||||
// log-only `compact/*` records and the replacement node cleanly outside a
|
||||
// step, so a crash mid-compaction leaves an inert orphan the turn-repair
|
||||
// closes — never a half-open step.
|
||||
ctx.on('agent/pre-step', async (agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal) => {
|
||||
try {
|
||||
const result = await this.compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)
|
||||
if (result) {
|
||||
const after = this.estimateTokens(agent.session.deriveMessages(), fullSystemPrompt)
|
||||
ctx.logger.info(
|
||||
`compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` +
|
||||
`(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` +
|
||||
`~${result.shadowedTokenCount} tokens) ` +
|
||||
`→ ${after} estimated tokens after compaction`,
|
||||
)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// A failed compaction must not prevent the model call — the surface is
|
||||
// untouched on failure, so the loop derives the full history and the
|
||||
// call proceeds.
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
ctx.logger.warn(`compaction failed: ${msg}; proceeding with full history`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Token estimation (overridable hooks) ----
|
||||
|
||||
// TODO: char/4 is a coarse heuristic. Replace with an exact count — a real
|
||||
// tokenizer, or the provider's post-response `usage` (input tokens) fed back
|
||||
// as a correction — so threshold decisions match the model's actual budget.
|
||||
/**
|
||||
* Estimate the token count of content blocks — char/4 with per-block
|
||||
* overhead. Override in a subclass to plug in a real tokenizer.
|
||||
*/
|
||||
estimateContentTokens(blocks: readonly ContentBlock[]): number {
|
||||
let tokens = 0
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
case 'reasoning':
|
||||
tokens += Math.ceil(block.text.length / 4) + BLOCK_OVERHEAD
|
||||
break
|
||||
case 'tool-call':
|
||||
tokens += Math.ceil(block.name.length / 4)
|
||||
+ Math.ceil(block.arguments.length / 4)
|
||||
+ BLOCK_OVERHEAD
|
||||
break
|
||||
case 'tool-result':
|
||||
tokens += this.estimateContentTokens(block.content) + BLOCK_OVERHEAD
|
||||
break
|
||||
case 'image':
|
||||
tokens += IMAGE_TOKEN_COST
|
||||
break
|
||||
default:
|
||||
// Unknown block types (merge-extensible ContentBlockMap):
|
||||
// estimate conservatively via JSON stringify.
|
||||
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / 4)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate token count for a single session event. Returns 0 for non-message
|
||||
* event types (boundaries, chunks, usage, errors, compact markers).
|
||||
*/
|
||||
estimateEventTokens(event: SessionEvent): number {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
case 'assistant/message':
|
||||
case 'context/message':
|
||||
case 'steering/message':
|
||||
case 'tool/result':
|
||||
return this.estimateContentTokens(event.data.content)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Estimate total tokens across a list of messages plus optional system prompt. */
|
||||
estimateTokens(messages: readonly Message[], systemPrompt?: string): number {
|
||||
let total = 0
|
||||
for (const msg of messages) {
|
||||
total += this.estimateContentTokens(msg.content)
|
||||
total += ROLE_OVERHEAD
|
||||
}
|
||||
if (systemPrompt) total += Math.ceil(systemPrompt.length / 4)
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize conversation text into content blocks via `agent/request` plus
|
||||
* `ctx.llm.stream()` assembled through a `BlockAssembler` (the single
|
||||
* model-call surface).
|
||||
* Override in a subclass for a template or remote summarizer.
|
||||
*
|
||||
* Honors the adapter failure contract: an adapter may report a model failure
|
||||
* by throwing from `stream()` (propagated here) OR by ending the stream with
|
||||
* a `finish {kind:'error'|'aborted'}` chunk — the latter is re-thrown so a
|
||||
* provider error never yields an empty summary.
|
||||
*
|
||||
* Forwards `signal` into `GenerateOptions.signal` so an abort/dispose tears
|
||||
* down the in-flight summarization rather than orphaning the model call.
|
||||
*/
|
||||
async summarize(text: string, agent: Agent, turn: number, step: number, signal?: AbortSignal): Promise<ContentBlock[]> {
|
||||
const assembler = new BlockAssembler()
|
||||
const options: GenerateOptions = {
|
||||
model: this.config.summarizationModel || agent.options.model || '',
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }],
|
||||
}],
|
||||
system: SUMMARIZE_SYSTEM_PROMPT,
|
||||
maxTokens: this.config.maxTokens,
|
||||
sessionId: agent.session.id,
|
||||
}
|
||||
// exactOptionalPropertyTypes: only set `signal` when present — assigning
|
||||
// `undefined` to an optional `signal?: AbortSignal` is a type error.
|
||||
if (signal) options.signal = signal
|
||||
const request = await this.ctx.waterfall('agent/request', agent, turn, step, options, () => Promise.resolve(options))
|
||||
if (!request.model) {
|
||||
throw new Error('no model available for summarization: set BasicCompactConfig.summarizationModel, AgentOptions.model, or supply one via the agent/request waterfall')
|
||||
}
|
||||
for await (const chunk of this.ctx.llm.stream(request)) {
|
||||
assembler.push(chunk)
|
||||
}
|
||||
|
||||
const error = finishError(assembler.finish)
|
||||
if (error) throw error
|
||||
|
||||
const summary = this._textOnly(assembler.message().content)
|
||||
if (!summary.some(block => block.type === 'text' && block.text.trim().length > 0)) {
|
||||
throw new Error('summarization produced no text summary content')
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
// ---- Core API (implements the abstract contract) ----
|
||||
|
||||
/**
|
||||
* The sole token-pressure gate: estimate the current surface-derived history,
|
||||
* and if it exceeds the threshold (`contextWindow * thresholdRatio`), compact
|
||||
* the oldest surface nodes outside the `retainTokens` budget. The auto-
|
||||
* compaction listener delegates here rather than pre-checking, so this is the
|
||||
* only place the decision lives.
|
||||
*
|
||||
* Retention is a UNIFORM tail→head walk over the whole surface — turn
|
||||
* boundaries play NO role. Walking node-by-node from the tail and summing
|
||||
* token estimates, once the retained total reaches `retainTokens` the cutoff
|
||||
* is rounded to a balanced tool-pairing boundary: if the cut before the
|
||||
* retained node is unbalanced (an unanswered tool-call sits before it — i.e.
|
||||
* it is mid-step), the walk continues head-ward until the cut is balanced so
|
||||
* the whole step is retained (never splitting a step's tool-calls from their
|
||||
* results); if it stopped on a free node (a node belonging to no step), that
|
||||
* cut is already balanced. This always rounds toward retaining MORE (retained
|
||||
* ≥ `retainTokens`) and is boundary-safe by construction — no separate snap
|
||||
* pass.
|
||||
*
|
||||
* The compacted range is always anchored at the surface HEAD (`nodes[0]`):
|
||||
* auto-compaction re-consolidates any prior head checkpoint into one fresh
|
||||
* checkpoint. Declines (`null`) when nothing is over threshold, when the whole
|
||||
* surface fits the retain budget, or when no balanced cutoff exists in the
|
||||
* compactable range (its only content is an open tail step — retry once it
|
||||
* closes).
|
||||
*/
|
||||
override async compactIfNeeded(
|
||||
agent: Agent,
|
||||
turn: number,
|
||||
step: number,
|
||||
fullSystemPrompt: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
const session = agent.session
|
||||
const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio)
|
||||
let result: CompactionResult | null = null
|
||||
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt++) {
|
||||
const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt)
|
||||
if (totalTokens < threshold) return result
|
||||
|
||||
const range = this._compactableRange(session)
|
||||
if (range === null) {
|
||||
/* v8 ignore else -- defensive for non-standard subclass mutations; the concrete replace keeps a compactable head checkpoint. */
|
||||
if (result === null) return null
|
||||
/* v8 ignore next -- paired with the ignored defensive branch above. */
|
||||
break
|
||||
}
|
||||
|
||||
result = await this.compactRegion(session, range.start, range.end, agent, turn, step, signal)
|
||||
}
|
||||
|
||||
const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt)
|
||||
if (totalTokens < threshold) return result
|
||||
|
||||
throw new Error(
|
||||
`compaction still above threshold after ${this.config.compactionRetries + 1} compaction attempts `
|
||||
+ `(${totalTokens} estimated tokens >= threshold ${threshold})`,
|
||||
)
|
||||
}
|
||||
|
||||
override async compactRegion(
|
||||
session: Session,
|
||||
start: number,
|
||||
end: number,
|
||||
agent: Agent,
|
||||
turn: number,
|
||||
step: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult> {
|
||||
// Resolve the range by surface POSITION, not numeric seq interval. A prior
|
||||
// replace lands a fresh high-seq summary node AT the shadowed range's
|
||||
// position, so the surface order (head→tail) no longer tracks seq order —
|
||||
// `[newSummarySeq, olderRetainedSeq, …]` is normal. Indexing into the
|
||||
// ordered node list and slicing it is the only correct way to read a range;
|
||||
// a `node.seq >= start && node.seq <= end` interval test would mis-collect
|
||||
// nodes (and `start > end` would falsely reject) once that happens.
|
||||
const nodes = session.surface.nodes
|
||||
const startIdx = nodes.findIndex(n => n.seq === start)
|
||||
const endIdx = nodes.findIndex(n => n.seq === end)
|
||||
if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`)
|
||||
if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`)
|
||||
if (startIdx > endIdx) {
|
||||
throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`)
|
||||
}
|
||||
|
||||
// The region must never split a step's assistant-message tool-calls from
|
||||
// their tool/results (which would orphan one side and produce a transcript
|
||||
// every provider rejects). A region is safe iff BOTH its edges are balanced
|
||||
// cuts: the cut before `start`, and the cut after `end`. A node that belongs
|
||||
// to no step (pre-step user message, inter-step steering, injection context)
|
||||
// is a balanced (free) boundary; an `end` inside an open (unclosed) tail step
|
||||
// leaves the cut after it unbalanced (the open tool-call has no result yet),
|
||||
// so it is rejected. See dsh-session's tool-pairing balance check.
|
||||
const events = session.events
|
||||
if (!isToolPairingBalanced(nodes, events, start)) {
|
||||
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
|
||||
}
|
||||
// The cut after `end` is named by `end`'s surface successor, or `null` when
|
||||
// `end` is the tail.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const afterEnd: number | null = nodes[endIdx]!.next
|
||||
if (!isToolPairingBalanced(nodes, events, afterEnd)) {
|
||||
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
|
||||
}
|
||||
|
||||
if (this._isCompactionInProgress(session)) {
|
||||
throw new Error('compaction already in progress')
|
||||
}
|
||||
|
||||
// Compaction's events (compact/* and the replacement user/message) must be
|
||||
// turn-enclosed: the session-log contract rejects any plugin event appended
|
||||
// outside an open turn. Auto-compaction satisfies this — it runs on the
|
||||
// `agent/pre-step` seam, after `turn/start` and before `step/start`, so
|
||||
// strictly inside the open turn (but outside any step). A manual call on a
|
||||
// fully-closed session has no turn to enclose the events, so reject rather
|
||||
// than emit an un-enclosed run.
|
||||
const openTurn = this._openTurn(session)
|
||||
if (openTurn === null) {
|
||||
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
|
||||
}
|
||||
// Slice the ordered surface nodes [startIdx, endIdx] inclusive — the
|
||||
// shadowed range is positional, so this is the set the replace op covers.
|
||||
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(n => n.seq)
|
||||
|
||||
// --- Acquire lock ---
|
||||
const startEvent = session.append('compact/start', { turn: openTurn })
|
||||
|
||||
try {
|
||||
// --- Extract text and summarize ---
|
||||
const text = this._extractText(session, shadowedSeqs)
|
||||
const summary = await this.summarize(text, agent, turn, step, signal)
|
||||
|
||||
// Estimate token count of the shadowed content for provenance.
|
||||
let shadowedTokenCount = 0
|
||||
for (const seq of shadowedSeqs) {
|
||||
// seq comes from a surface node — always a valid log index by construction.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
shadowedTokenCount += this.estimateEventTokens(session.events[seq]!)
|
||||
}
|
||||
const framedSummary = this._frameSummary(summary)
|
||||
const framedSummaryTokenCount = this.estimateContentTokens(framedSummary)
|
||||
if (framedSummaryTokenCount >= shadowedTokenCount) {
|
||||
throw new Error(
|
||||
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`,
|
||||
)
|
||||
}
|
||||
// --- Provenance record (log-only) ---
|
||||
const summaryEvent = session.append('compact/summary', {
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs,
|
||||
shadowedTokenCount,
|
||||
})
|
||||
|
||||
// --- Surface replacement ---
|
||||
// The user/message directly shadows all compacted surface nodes with a
|
||||
// single replace op. It is the ONLY surface event in the compaction
|
||||
// sequence — compact/start, compact/summary, and compact/end are log-only
|
||||
// (surfaceOp is rejected by the compiler for non-SurfaceEventType).
|
||||
// The landed content is FRAMED (checkpoint preamble + tag-wrapped summary);
|
||||
// the compact/summary provenance event above holds the raw model output.
|
||||
session.append('user/message', {
|
||||
content: framedSummary,
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start, end },
|
||||
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
|
||||
})
|
||||
|
||||
// --- Release lock (log-only) ---
|
||||
// Appended LAST so the lock brackets the WHOLE operation: a crash between
|
||||
// compact/start and here leaves a detectable orphaned lock (a compact/start
|
||||
// with no matching compact/end) rather than a compact/end that falsely
|
||||
// claims compaction finished before the surface replacement landed.
|
||||
const endEvent = session.append('compact/end', { turn: openTurn })
|
||||
|
||||
return {
|
||||
startSeq: startEvent.seq,
|
||||
summarySeq: summaryEvent.seq,
|
||||
endSeq: endEvent.seq,
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs,
|
||||
shadowedTokenCount,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Always release the lock — append compact/end with the error so a
|
||||
// wedged lock is impossible.
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
session.append('compact/end', { turn: openTurn, error: msg })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Internal helpers ----
|
||||
|
||||
/**
|
||||
* Frame the raw summary blocks into the content that lands on the surface:
|
||||
* a checkpoint preamble (so a resuming model reads it as a checkpoint, not a
|
||||
* fresh user request) followed by the summary wrapped in
|
||||
* {@link SUMMARY_OPEN_TAG}/{@link SUMMARY_CLOSE_TAG}. The tags make a prior
|
||||
* checkpoint detectable in the transcript on the next compaction cycle, which
|
||||
* triggers the merge rule in the summarization prompt. The raw, unframed
|
||||
* `summary` is preserved separately on the `compact/summary` provenance event.
|
||||
*/
|
||||
private _frameSummary(summary: readonly ContentBlock[]): ContentBlock[] {
|
||||
return [
|
||||
{ type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
|
||||
...summary,
|
||||
{ type: 'text', text: SUMMARY_CLOSE_TAG },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a compaction is currently in progress for `session` — an unmatched
|
||||
* `compact/start` (no later `compact/end`) WITHIN the current turn.
|
||||
*
|
||||
* The scan is scoped to the current turn: walking back from the tail it stops
|
||||
* at the first `turn/end` (the boundary closing the prior turn). A
|
||||
* `compact/start` left orphaned by a crash mid-compaction lives in a turn that
|
||||
* persistence repair then closes with a synthetic `turn/end`; scoping here so
|
||||
* that a stale orphan from a PAST turn cannot wedge compaction forever (it sits
|
||||
* before the nearest `turn/end`, so the scan never reaches it). An in-progress
|
||||
* compaction's `compact/start` is always in the still-open current turn,
|
||||
* before any `turn/end`, so it is still detected.
|
||||
*/
|
||||
private _isCompactionInProgress(session: Session): boolean {
|
||||
const events = session.events
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
// Index bounded by i >= 0 and i < events.length — never undefined.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const e = events[i]!
|
||||
if (e.type === 'compact/start') return true
|
||||
if (e.type === 'compact/end') break
|
||||
// A turn/end bounds the scan: anything before it belongs to a prior
|
||||
// (closed) turn and cannot be an in-progress compaction of THIS turn.
|
||||
if (e.type === 'turn/end') break
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Resolve the next head-anchored compactable surface range, or `null`. */
|
||||
private _compactableRange(session: Session): { start: number; end: number } | null {
|
||||
const nodes = session.surface.nodes
|
||||
if (nodes.length === 0) return null
|
||||
|
||||
const events = session.events
|
||||
const retainBudget = this.config.retainTokens
|
||||
|
||||
// Walk tail→head summing per-node token estimates. `keepFromIdx` is the
|
||||
// index of the OLDEST node we retain verbatim; everything strictly older
|
||||
// (`[0, keepFromIdx - 1]`) is the compactable range.
|
||||
let accumulated = 0
|
||||
let keepFromIdx = nodes.length // nothing retained yet
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const node = nodes[i]!
|
||||
const event = events[node.seq]
|
||||
/* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */
|
||||
if (event) accumulated += this.estimateEventTokens(event)
|
||||
keepFromIdx = i
|
||||
if (accumulated >= retainBudget) break
|
||||
}
|
||||
|
||||
// The whole surface fits the retain budget — nothing to compact.
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
// Round the cutoff to a tool-pairing boundary: if the cut before
|
||||
// `nodes[keepFromIdx]` is unbalanced (an unanswered tool-call sits before
|
||||
// it — i.e. it is mid-step), extend the retained side head-ward until the
|
||||
// cut is balanced, so the compacted range ends without splitting an
|
||||
// assistant↔result pair. A node that belongs to no step is already a
|
||||
// balanced (free) boundary. Decline if no balanced cut exists at or below
|
||||
// `keepFromIdx` (the compactable range is only an un-splittable open tail
|
||||
// step — retry once it closes).
|
||||
while (keepFromIdx > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break
|
||||
keepFromIdx -= 1
|
||||
}
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
// The compacted range is [head … keepFromIdx - 1], anchored at the head.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const firstSeq = nodes[0]!.seq
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const cutoffSeq = nodes[keepFromIdx - 1]!.seq
|
||||
return { start: firstSeq, end: cutoffSeq }
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep ONLY text blocks from the model-produced summary before storing it.
|
||||
*
|
||||
* The summary lands on the surface as a synthesized `user/message` (see
|
||||
* {@link _frameSummary}), so the only block type that is both useful and safe
|
||||
* there is `text`. A model assistant message can otherwise carry `reasoning`
|
||||
* (private chain-of-thought, must not leak into the durable checkpoint) and
|
||||
* `tool-call` blocks — and a surviving `tool-call` in a user message would be
|
||||
* an orphaned call with no matching `tool-result`, exactly the tool-pairing
|
||||
* breakage compaction works to avoid. Filtering to text drops both.
|
||||
*/
|
||||
private _textOnly(blocks: readonly ContentBlock[]): ContentBlock[] {
|
||||
return blocks.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn number of the currently OPEN turn — a `turn/start` not yet
|
||||
* followed by its `turn/end` — or `null` if the session has no open turn.
|
||||
*
|
||||
* Compaction's events must be enclosed in a turn, so scanning back from the
|
||||
* tail: a `turn/start` means that turn is open (return it); a `turn/end` means
|
||||
* the most recent turn already closed (return null). The whole compaction
|
||||
* sequence (compact/start … compact/end) is stamped with this turn.
|
||||
*/
|
||||
private _openTurn(session: Session): number | null {
|
||||
for (let i = session.events.length - 1; i >= 0; i--) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const e = session.events[i]!
|
||||
if (e.type === 'turn/start') return e.data.turn
|
||||
if (e.type === 'turn/end') return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plain-text conversation from a set of surface node seqs, for
|
||||
* feeding into the summarization model. Walks the seqs in the order given
|
||||
* (surface order, as `compactRegion` slices the surface-node list) so the
|
||||
* summary follows the conversation as the model sees it — which, after a
|
||||
* `replace`, is NOT ascending log-seq order (a high-seq summary node heads the
|
||||
* surface before older retained lower-seq nodes).
|
||||
*/
|
||||
private _extractText(session: Session, seqs: number[]): string {
|
||||
const lines: string[] = []
|
||||
|
||||
// Walk seqs in the order given (surface order, as compactRegion slices the
|
||||
// surface-node list) — NOT ascending log-seq order. After a replace the
|
||||
// summary node carries a fresh high seq while sitting at the head of the
|
||||
// surface before older retained lower-seq nodes, so a log-order scan would
|
||||
// feed the transcript out of order and break the checkpoint-merge prompt.
|
||||
for (const seq of seqs) {
|
||||
const event = session.events[seq]
|
||||
/* v8 ignore next -- seq is a surface-node seq, always a valid log index by construction */
|
||||
if (!event) continue
|
||||
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
const text = this._blocksToText(event.data.content)
|
||||
if (text) lines.push(`User: ${text}`)
|
||||
break
|
||||
}
|
||||
case 'assistant/message': {
|
||||
const text = this._blocksToText(event.data.content)
|
||||
if (text) lines.push(`Assistant: ${text}`)
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
const text = this._blocksToText(event.data.content)
|
||||
const label = event.data.isError ? 'Tool error' : 'Tool result'
|
||||
if (text) lines.push(`${label} (call ${event.data.callId}): ${text}`)
|
||||
break
|
||||
}
|
||||
case 'context/message': {
|
||||
const text = this._blocksToText(event.data.content)
|
||||
if (text) lines.push(`[Context: ${text}]`)
|
||||
break
|
||||
}
|
||||
case 'steering/message': {
|
||||
const text = this._blocksToText(event.data.content)
|
||||
if (text) lines.push(`[Steering: ${text}]`)
|
||||
break
|
||||
}
|
||||
// SessionEventMap is merge-extensible — unknown types are
|
||||
// non-message events that carry no extractable text.
|
||||
/* v8 ignore next 2 -- seqs only name surface nodes, always one of the 5 handled SurfaceEventTypes; unreachable */
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render content blocks to a single plain-text string for the summarization
|
||||
* prompt. Text and reasoning contribute their text; every other block type
|
||||
* contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`,
|
||||
* …) so the summarizer is told what non-text content existed in the region
|
||||
* rather than silently losing it. Blocks join with newlines; empty-text
|
||||
* blocks contribute nothing.
|
||||
*/
|
||||
private _blocksToText(blocks: readonly ContentBlock[]): string {
|
||||
const parts: string[] = []
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
if (block.text) parts.push(block.text)
|
||||
break
|
||||
case 'reasoning':
|
||||
if (block.text) parts.push(`[reasoning: ${block.text}]`)
|
||||
break
|
||||
case 'tool-call':
|
||||
parts.push(`[tool-call: ${block.name}(${block.arguments})]`)
|
||||
break
|
||||
case 'tool-result': {
|
||||
const inner = this._blocksToText(block.content)
|
||||
parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]')
|
||||
break
|
||||
}
|
||||
case 'image':
|
||||
parts.push('[image]')
|
||||
break
|
||||
// ContentBlockMap is merge-extensible — render an unknown block as a
|
||||
// bare type-tagged placeholder so a plugin-added block type is still
|
||||
// signalled to the summarizer rather than dropped.
|
||||
default:
|
||||
parts.push(`[${(block as ContentBlock).type}]`)
|
||||
}
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
export default BasicCompactService
|
||||
81
packages/compact/compact-basic/src/types.ts
Normal file
81
packages/compact/compact-basic/src/types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Configuration vocabulary for the basic compaction backend.
|
||||
*
|
||||
* Every tunable lives here, in the implementation — the abstract contract
|
||||
* (`@deepseek-ai/dsh-compact`) carries no config, because thresholds and
|
||||
* retention policy are HOW decisions a different backend would make
|
||||
* differently.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-basic/types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Backend configuration. Every knob is REQUIRED except `auto`: there is no
|
||||
* concrete data yet to justify default thresholds/budgets, so a consumer must
|
||||
* state each value explicitly rather than inherit a guessed default. `auto`
|
||||
* alone defaults to `true` (auto-compaction is the intended posture).
|
||||
*/
|
||||
export interface BasicCompactConfig {
|
||||
/** Context window size in tokens. */
|
||||
contextWindow: number
|
||||
/** Compact when estimated token usage exceeds this fraction of context window. */
|
||||
thresholdRatio: number
|
||||
/** Number of tokens of recent context to retain during compaction. */
|
||||
retainTokens: number
|
||||
/** Model to use for summarization (`''` — uses the agent's model). */
|
||||
summarizationModel: string
|
||||
/** Provider generation cap for the summarization call. */
|
||||
maxTokens: number
|
||||
/** Extra compaction attempts when the first compacted surface is still over threshold. */
|
||||
compactionRetries: number
|
||||
/** Enable automatic compaction on the `agent/pre-step` seam (default true). */
|
||||
auto?: boolean
|
||||
}
|
||||
|
||||
/** Resolved config with `auto` defaulted. */
|
||||
export type ResolvedConfig = Required<BasicCompactConfig>
|
||||
|
||||
/**
|
||||
* Default `auto` when unset and reject nonsensical numeric knobs.
|
||||
*
|
||||
* Convergence is not a static config invariant: provider generation caps can be
|
||||
* spent on hidden or surfaced reasoning tokens, and the model may emit a summary
|
||||
* of unpredictable size. The backend instead enforces convergence dynamically:
|
||||
* each committed summary must be smaller than the content it shadows, and
|
||||
* `compactIfNeeded` may re-compact up to `compactionRetries` extra times before
|
||||
* throwing if the surface still exceeds the threshold.
|
||||
*/
|
||||
export function resolveConfig(config: BasicCompactConfig): ResolvedConfig {
|
||||
const resolved: ResolvedConfig = { auto: true, ...config }
|
||||
|
||||
assertPositiveInteger('contextWindow', resolved.contextWindow)
|
||||
assertRatio('thresholdRatio', resolved.thresholdRatio)
|
||||
assertNonNegativeInteger('retainTokens', resolved.retainTokens)
|
||||
assertPositiveInteger('maxTokens', resolved.maxTokens)
|
||||
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
|
||||
if (typeof resolved.summarizationModel !== 'string') {
|
||||
throw new Error('BasicCompactConfig: summarizationModel must be a string.')
|
||||
}
|
||||
if (typeof resolved.auto !== 'boolean') {
|
||||
throw new Error('BasicCompactConfig: auto must be a boolean.')
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive integer.`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonNegativeInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a non-negative integer.`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRatio(name: string, value: number): void {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1].`)
|
||||
}
|
||||
}
|
||||
1707
packages/compact/compact-basic/tests/compact-basic.spec.ts
Normal file
1707
packages/compact/compact-basic/tests/compact-basic.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
156
packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
Normal file
156
packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* CBR-001 regression: a compaction checkpoint that the REAL loop lands is a
|
||||
* free surface boundary (it carries no tool-call/result pair), so it must be a
|
||||
* valid region edge on BOTH sides. A surface-anchored balance check sees that;
|
||||
* the abandoned log-position scan did not.
|
||||
*
|
||||
* The loop fires the compaction seam mid-flight, so the landed checkpoint
|
||||
* `user/message{replace}` sits at a HIGH log seq positioned beside the current
|
||||
* step even though its SURFACE position is the head. A log-position forward scan
|
||||
* from the checkpoint reaches the step's own later `assistant/message` and
|
||||
* wrongly reports the checkpoint as mid-step — refusing it as a region end. A
|
||||
* SECOND compaction that re-summarizes just that head checkpoint (region end ==
|
||||
* checkpoint) therefore throws and is swallowed, so the surface never
|
||||
* re-consolidates.
|
||||
*
|
||||
* This drives a real auto-compaction through the agent-loop and asserts the
|
||||
* landed checkpoint balances on both sides AND that re-compacting it (end ==
|
||||
* checkpoint) succeeds. RED on the log-position predicates; GREEN once alignment
|
||||
* is decided from surface tool-pairing balance.
|
||||
*/
|
||||
|
||||
const TOKENS_PER_BLOCK = 10
|
||||
|
||||
class ReproCompactService extends BasicCompactService {
|
||||
override estimateContentTokens(blocks: readonly ContentBlock[]): number {
|
||||
return blocks.length * TOKENS_PER_BLOCK
|
||||
}
|
||||
|
||||
override async summarize(): Promise<ContentBlock[]> {
|
||||
return [{ type: 'text', text: 'CHECKPOINT SUMMARY' }]
|
||||
}
|
||||
}
|
||||
|
||||
/** Each call emits one tool-call until exhausted, then a final text answer. */
|
||||
class StepwiseToolAdapter extends LlmAdapter {
|
||||
calls = 0
|
||||
constructor(private toolSteps: number) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const n = this.calls
|
||||
this.calls += 1
|
||||
if (n < this.toolSteps) {
|
||||
const id = CallId(`c${n}`)
|
||||
const args = `{"i":${n}}`
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: `step ${n}` } }
|
||||
yield { type: 'block-start', index: 1, blockType: 'tool-call' }
|
||||
yield { type: 'block-end', index: 1, block: { type: 'tool-call', id, name: 'work', arguments: args } }
|
||||
yield { type: 'finish', reason: { kind: 'tool-calls' } }
|
||||
return
|
||||
}
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'all done' } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
async function harness(toolSteps: number): Promise<{ ctx: Context; compact: ReproCompactService }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(Invariants, {})
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'work',
|
||||
description: 'does work',
|
||||
parameters: { i: { type: 'number' } },
|
||||
async execute() {
|
||||
return [{ type: 'text', text: 'work result' }]
|
||||
},
|
||||
}))
|
||||
// Tiny window so a couple of tool steps cross the threshold and compaction
|
||||
// fires within the runaway turn.
|
||||
const compact = new ReproCompactService(ctx, {
|
||||
auto: true,
|
||||
contextWindow: 64,
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 20,
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
})
|
||||
return { ctx, compact }
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => {
|
||||
it('the head checkpoint the loop lands is a balanced cut on both sides', async () => {
|
||||
const { ctx } = await harness(8)
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(AgentId('repro'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'do a long multi-step task' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
// A compaction ran: at least one checkpoint landed on the surface.
|
||||
const checkpoints = events.filter(
|
||||
(e): e is SurfaceEvent =>
|
||||
e.type === 'user/message'
|
||||
&& typeof (e as SurfaceEvent).surfaceOp === 'object',
|
||||
)
|
||||
expect(checkpoints.length).toBeGreaterThan(0)
|
||||
|
||||
// The loop fired compaction mid-flight, so each landed checkpoint sits at a
|
||||
// high log seq beside the step it landed in, even though its SURFACE
|
||||
// position is the head of the range it shadowed. A checkpoint carries no
|
||||
// tool-call/result pair (only summarized prose), so every checkpoint still
|
||||
// on the surface must be a balanced cut on BOTH sides — the cut before it
|
||||
// (region START) and the cut after it (region END). The abandoned
|
||||
// log-position scan reported the END as mis-aligned because the forward log
|
||||
// scan reached the neighbouring step's assistant/message.
|
||||
const nodes = agent.session.surface.nodes
|
||||
for (const cp of checkpoints) {
|
||||
const node = nodes.find(n => n.seq === cp.seq)
|
||||
if (!node) continue // shadowed by a later checkpoint — no longer an edge.
|
||||
expect(isToolPairingBalanced(nodes, events, node.seq),
|
||||
`checkpoint seq ${node.seq} must be a balanced region START`).toBe(true)
|
||||
expect(isToolPairingBalanced(nodes, events, node.next),
|
||||
`checkpoint seq ${node.seq} must be a balanced region END`).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
16
packages/compact/compact-basic/tsconfig.json
Normal file
16
packages/compact/compact-basic/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../compact" }
|
||||
]
|
||||
}
|
||||
@@ -10,7 +10,7 @@ This package is the interface tier of the compaction capability, split so each c
|
||||
| `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: char/4 estimation + token-budget retention + `llm.stream()` summarization |
|
||||
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
|
||||
|
||||
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md).
|
||||
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md).
|
||||
|
||||
## Service API (`ctx.compact`)
|
||||
|
||||
@@ -18,10 +18,10 @@ Both methods are **abstract** — the backend owns the entire strategy (token es
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `compactIfNeeded(session, systemPrompt?, model?, signal?)` | Estimate the history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. |
|
||||
| `compactRegion(session, start, end, model, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start > end`. |
|
||||
| `compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint supplies the agent, lifecycle context, assembled `fullSystemPrompt`, and turn `signal`; router-aware summarizers can use the agent lifecycle context to route their own model call through `agent/request`. |
|
||||
| `compactRegion(session, start, end, agent, turn, step, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
|
||||
|
||||
Both methods take an optional `signal: AbortSignal`. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is not a parameter — it is recoverable from the log (the currently-open turn), so the backend stamps it without the caller supplying it.
|
||||
`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The session being compacted comes from the agent context; the turn that the `compact/*` events belong to is recoverable from the log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.
|
||||
|
||||
## Surface contract
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* depends on `dsh-session` and `dsh-llm`: the contract's verbs are defined over
|
||||
* a `Session` and its output is the `ContentBlock` vocabulary. That deviation
|
||||
* from the "interface depends only on cordis" guidance is intentional and
|
||||
* recorded in the [compaction capability-seam RFC](../../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md).
|
||||
* recorded in the [compaction capability-seam RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact
|
||||
*/
|
||||
@@ -27,6 +27,12 @@ import type { CompactionResult } from './types.ts'
|
||||
|
||||
export type { CompactionResult } from './types.ts'
|
||||
|
||||
/** Minimal agent context compaction needs without depending on the agent package. */
|
||||
export interface CompactAgentContext {
|
||||
session: Session
|
||||
options: { model?: string }
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
compact: CompactService
|
||||
@@ -62,24 +68,44 @@ export abstract class CompactService extends Service {
|
||||
/**
|
||||
* Check token pressure and compact if the conversation is too large.
|
||||
*
|
||||
* Estimates the current history size (optionally including a system prompt),
|
||||
* and if it exceeds the backend's threshold, compacts an older range via
|
||||
* {@link compactRegion}, keeping recent context intact.
|
||||
* Estimates the current surface-derived history size (including the system
|
||||
* prompt), and if it exceeds the backend's threshold, compacts an older range
|
||||
* via {@link compactRegion}, keeping recent context intact. Returns `null`
|
||||
* when no compaction is needed.
|
||||
*
|
||||
* @param session - the session whose surface may be compacted.
|
||||
* @param systemPrompt - optional system prompt, counted toward the estimate.
|
||||
* @param model - optional summarization model (falls back to backend config).
|
||||
* @param signal - optional cancellation signal. A backend that summarizes via
|
||||
* Scope and guarantees a backend MUST honor:
|
||||
* - **Surface-derived history only.** The decision is made against the history
|
||||
* derived from the session surface — the only thing compaction can act on.
|
||||
* Non-surface context injected downstream (into the request `messages` by a
|
||||
* later listener) is out of this accounting by construction.
|
||||
* - **Head-anchored, best-effort.** Auto-compaction consolidates from the
|
||||
* surface HEAD up to a balanced tool-pairing cutoff, so a prior head
|
||||
* checkpoint is
|
||||
* re-summarized into one fresh checkpoint (the surface holds at most one
|
||||
* auto-generated checkpoint, always at the head). It is best-effort over
|
||||
* CLOSED steps: when the only compactable content left is an un-splittable
|
||||
* open tail step, it declines (`null`) and retries once that step closes.
|
||||
* - **Single-unit overflow is out of scope.** If a single retained unit (one
|
||||
* closed step, or a large free node such as a pasted `user/message`) ALONE
|
||||
* exceeds the budget, compaction cannot help and the call may go out
|
||||
* over-budget. Bounding an individual unit's size is a separate concern.
|
||||
*
|
||||
* @param agent - agent context owning the session surface and model options.
|
||||
* @param turn - turn number of the pre-step checkpoint.
|
||||
* @param step - step number about to start.
|
||||
* @param fullSystemPrompt - assembled system prompt, counted toward the estimate.
|
||||
* @param signal - cancellation signal. A backend summarizing via
|
||||
* `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal`
|
||||
* so an abort/dispose tears down the in-flight summarization rather than
|
||||
* leaving an orphaned model call running past the cancellation.
|
||||
* @returns the compaction result, or `null` if no compaction was needed.
|
||||
*/
|
||||
abstract compactIfNeeded(
|
||||
session: Session,
|
||||
systemPrompt?: string,
|
||||
model?: string,
|
||||
signal?: AbortSignal,
|
||||
agent: CompactAgentContext,
|
||||
turn: number,
|
||||
step: number,
|
||||
fullSystemPrompt: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null>
|
||||
|
||||
/**
|
||||
@@ -89,22 +115,40 @@ export abstract class CompactService extends Service {
|
||||
* summarizes their content and appends a replacement surface node. Used by the
|
||||
* (future) `/compact` tool and internally by {@link compactIfNeeded}.
|
||||
*
|
||||
* The region MUST NOT split a step's `assistant/message` tool-calls from their
|
||||
* `tool/result`s, leaving the rehydrated transcript with a dangling tool-call
|
||||
* or an orphaned tool-result that every provider rejects. A region is safe iff
|
||||
* both its edges are balanced cuts on the surface: the cut before `start` and
|
||||
* the cut after `end` each have no unanswered tool-call before them. A node
|
||||
* that belongs to no step (a pre-step user message, inter-step steering, or an
|
||||
* injection context message) is a balanced (free) boundary; an `end` inside an
|
||||
* open (unclosed) tail step is invalid — its tool-calls have no results yet.
|
||||
* `dsh-session` exports `isToolPairingBalanced` for this check.
|
||||
*
|
||||
* @param session - the session whose surface is mutated.
|
||||
* @param start - inclusive seq of the first surface node to compact.
|
||||
* @param end - inclusive seq of the last surface node to compact.
|
||||
* @param model - summarization model.
|
||||
* @param agent - agent context used by router-aware summarizers.
|
||||
* @param turn - lifecycle turn forwarded to request-routing seams.
|
||||
* @param step - lifecycle step forwarded to request-routing seams.
|
||||
* @param signal - optional cancellation signal. A backend that summarizes via
|
||||
* `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal`
|
||||
* so an abort/dispose tears down the in-flight summarization rather than
|
||||
* leaving an orphaned model call running past the cancellation.
|
||||
* @throws if compaction is already in progress, or if `start`/`end` are not
|
||||
* valid surface nodes, or if `start > end`.
|
||||
* @throws if compaction is already in progress, if `start`/`end` are not
|
||||
* valid surface nodes, if `start` is positioned after `end` on the surface
|
||||
* (the range is a surface-POSITION span, not a numeric seq interval — a
|
||||
* prior replace can leave the surface non-monotonic in seq order), or if
|
||||
* either boundary is not a balanced tool-pairing cut (would split a step's
|
||||
* tool-call/result pair).
|
||||
*/
|
||||
abstract compactRegion(
|
||||
session: Session,
|
||||
start: number,
|
||||
end: number,
|
||||
model: string,
|
||||
agent: CompactAgentContext,
|
||||
turn: number,
|
||||
step: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult>
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* events are log-only markers (lock + provenance); only the five
|
||||
* surface-eligible types can carry `surfaceOp`. The actual surface mutation is
|
||||
* performed by a separate `user/message` event carrying the summary (see the
|
||||
* [compaction capability-seam RFC](../../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md)).
|
||||
* [compaction capability-seam RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)).
|
||||
*
|
||||
* Configuration lives in the backend, not here: the contract states WHAT
|
||||
* compaction produces, while every tunable (context window, thresholds,
|
||||
@@ -48,9 +48,16 @@ export interface CompactionResult {
|
||||
endSeq: number
|
||||
/** The summary content blocks produced by the backend. */
|
||||
summary: ContentBlock[]
|
||||
/** The seq range that was shadowed [start, end] inclusive. */
|
||||
/**
|
||||
* The surface-boundary pair that was shadowed: the seqs of the first
|
||||
* (`start`) and last (`end`) surface nodes of the replaced range. A
|
||||
* surface-POSITION span, not a numeric seq interval — after a prior replace
|
||||
* lands a fresh high-seq summary node at an older range's position, `start`
|
||||
* can be GREATER than `end`. {@link CompactionResult.shadowedSeqs} is the
|
||||
* authoritative set of shadowed nodes, in surface order.
|
||||
*/
|
||||
shadowedRange: { start: number; end: number }
|
||||
/** The seq numbers of all shadowed surface nodes. */
|
||||
/** The seqs of all shadowed surface nodes, in surface order. */
|
||||
shadowedSeqs: number[]
|
||||
/** Estimated token count of the shadowed content. */
|
||||
shadowedTokenCount: number
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Context } from 'cordis'
|
||||
import { CompactService } from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { CompactAgentContext } from '@deepseek-ai/dsh-compact'
|
||||
|
||||
/**
|
||||
* A trivial concrete CompactService implementing the abstract contract. The
|
||||
@@ -15,10 +16,11 @@ class StubCompactService extends CompactService {
|
||||
lastSignal: AbortSignal | undefined
|
||||
|
||||
override async compactIfNeeded(
|
||||
_session: Session,
|
||||
_systemPrompt?: string,
|
||||
_model?: string,
|
||||
signal?: AbortSignal,
|
||||
_agent: CompactAgentContext,
|
||||
_turn: number,
|
||||
_step: number,
|
||||
_fullSystemPrompt: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
this.lastSignal = signal
|
||||
return null
|
||||
@@ -28,7 +30,9 @@ class StubCompactService extends CompactService {
|
||||
session: Session,
|
||||
start: number,
|
||||
end: number,
|
||||
_model: string,
|
||||
_agent: CompactAgentContext,
|
||||
_turn: number,
|
||||
_step: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult> {
|
||||
this.lastSignal = signal
|
||||
@@ -54,6 +58,10 @@ class StubCompactService extends CompactService {
|
||||
}
|
||||
|
||||
describe('CompactService seam', () => {
|
||||
function stubAgent(session: Session, model?: string): CompactAgentContext {
|
||||
return { session, options: model === undefined ? {} : { model } }
|
||||
}
|
||||
|
||||
it('registers as ctx.compact', () => {
|
||||
const ctx = new Context()
|
||||
void new StubCompactService(ctx)
|
||||
@@ -72,7 +80,8 @@ describe('CompactService seam', () => {
|
||||
it('exposes the abstract contract methods', async () => {
|
||||
const ctx = new Context()
|
||||
const svc = new StubCompactService(ctx)
|
||||
expect(await svc.compactIfNeeded(new Session(SessionId('s')))).toBeNull()
|
||||
const session = new Session(SessionId('s'))
|
||||
expect(await svc.compactIfNeeded(stubAgent(session), 1, 1, '', new AbortController().signal)).toBeNull()
|
||||
})
|
||||
|
||||
it('compact/* events merge into SessionEventMap and are log-only', async () => {
|
||||
@@ -80,7 +89,7 @@ describe('CompactService seam', () => {
|
||||
const svc = new StubCompactService(ctx)
|
||||
const session = new Session(SessionId('s'))
|
||||
|
||||
const result = await svc.compactRegion(session, 0, 0, 'm')
|
||||
const result = await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), 1, 1)
|
||||
|
||||
const startEvent = session.events.find(e => e.type === 'compact/start')
|
||||
expect(startEvent).toBeDefined()
|
||||
@@ -98,10 +107,10 @@ describe('CompactService seam', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
const controller = new AbortController()
|
||||
|
||||
await svc.compactRegion(session, 0, 0, 'm', controller.signal)
|
||||
await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), 1, 1, controller.signal)
|
||||
expect(svc.lastSignal).toBe(controller.signal)
|
||||
|
||||
await svc.compactIfNeeded(session, undefined, undefined, controller.signal)
|
||||
await svc.compactIfNeeded(stubAgent(session), 1, 1, '', controller.signal)
|
||||
expect(svc.lastSignal).toBe(controller.signal)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,8 @@ forever:
|
||||
STEP loop:
|
||||
drain steering
|
||||
assembly = systemPrompt.assemble()
|
||||
await serial agent/pre-step ⟵ surface mutation (compaction) outside the step
|
||||
session('step/start')
|
||||
request = waterfall agent/request
|
||||
stream llm.stream(request) → session('assistant/chunk')
|
||||
message = waterfall agent/step-result
|
||||
@@ -74,8 +76,8 @@ Cancellation: `agent.cancel()` is the single public stop primitive — it clears
|
||||
### What is NOT here
|
||||
|
||||
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
|
||||
- Hooks: `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`
|
||||
- Compaction: `agent/request`
|
||||
- Hooks: `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`
|
||||
- Compaction: `agent/pre-step`
|
||||
- Sandbox, permission, plan mode: `tools/execute`
|
||||
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred.
|
||||
- Persistence: `session/event` + `session/flush`
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-ll
|
||||
import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type { ReactLoopAgent } from './agent.ts'
|
||||
|
||||
@@ -147,10 +148,11 @@ export interface LoopHandle {
|
||||
* drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start
|
||||
* STEP loop:
|
||||
* drain steering → session('steering/message') ⟵ catches late steering
|
||||
* session('step/start'); emit agent/step-start ⟵ append before emit (the event-sourcing RFC)
|
||||
* assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble
|
||||
* await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step
|
||||
* session('step/start'); emit agent/step-start ⟵ append before emit (the event-sourcing RFC)
|
||||
* req = {model, system, tools, messages: session.deriveMessages(), signal}
|
||||
* req = waterfall agent/request ⟵ hooks/compaction/model-switch
|
||||
* req = waterfall agent/request ⟵ hooks/model-switch
|
||||
* stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks)
|
||||
* session('assistant/chunk'); emit agent/stream-chunk
|
||||
* msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
|
||||
@@ -386,30 +388,78 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
// (or turn-start listeners on the first step) joins before the request.
|
||||
drainSteering(ctx, agent, turn)
|
||||
|
||||
// The step's AbortController exists BEFORE any async pre-step work so a
|
||||
// dispose() or cancel() — in a synchronous turn-start listener or an
|
||||
// async listener whose effect fires before we block — always has an armed
|
||||
// abort to cancel against. isDisposed below covers disposal, which does
|
||||
// NOT set the cancel marker. Cleared on every exit path below.
|
||||
const abort = new AbortController()
|
||||
handle.setAbort(abort)
|
||||
|
||||
// Assemble the system prompt for this step. Done HERE (before step/start)
|
||||
// because the pre-step seam needs it: compaction measures token pressure
|
||||
// against the system prompt (it counts toward the budget). runStep reuses
|
||||
// this same assembly for the request, so the prompt is assembled once per
|
||||
// step.
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
const fullSystemPrompt = [renderPrompt(assembly), agent.options.systemPrompt ?? '']
|
||||
.filter(text => text.length > 0)
|
||||
.join('\n\n')
|
||||
|
||||
// Interruption landing after assembly: dispose() or cancel() in a
|
||||
// turn-start listener (or a listener whose promise resolved before the
|
||||
// await above) arms either handle.isDisposed() or handle.isCancelled().
|
||||
// The Abort was created first, so any concurrent abort also lands on it.
|
||||
// Drop the about-to-start step WITHOUT running the seam — no step is open
|
||||
// yet, so end the turn accordingly (disposed wins for an unambiguous
|
||||
// reason).
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
break
|
||||
}
|
||||
|
||||
// Pre-step surface-mutation checkpoint (compaction), fired OUTSIDE the
|
||||
// step: after `turn/start` (and the prior step's close) but before
|
||||
// `step/start`, so a compaction's log-only `compact/*` records and its
|
||||
// replacement node land cleanly outside any step (honest structure that
|
||||
// crash-safety relies on — a dangling `compact/start` sits before the
|
||||
// synthetic `turn/end` repair appends). Serial (awaited, in order, no
|
||||
// veto): each listener completes its surface mutation before the next, so
|
||||
// concurrent listeners cannot interleave their `session.append`s. A
|
||||
// throwing listener escapes to the outer catch, which closes the (not-yet-
|
||||
// open) step as a no-op and ends the turn via failTurn — a broken
|
||||
// pre-step plugin ends the turn, not the loop.
|
||||
await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, abort.signal)
|
||||
|
||||
// Interruption landing during the pre-step seam: do not open an empty
|
||||
// step. `agent/step-start` listeners get their own check below because
|
||||
// they necessarily run after step/start is appended/emitted.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
break
|
||||
}
|
||||
|
||||
session.append('step/start', { turn, step })
|
||||
stepOpen = true
|
||||
ctx.emit('agent/step-start', agent, turn, step)
|
||||
|
||||
const abort = new AbortController()
|
||||
handle.setAbort(abort)
|
||||
|
||||
// Cancel landing in the step-start window: a synchronous `agent/turn-start`
|
||||
// or `agent/step-start` listener (both fire before this point) can have
|
||||
// called `cancel()`, and `runStep` would otherwise run a full extra step
|
||||
// with no AbortController having observed it. Check the marker AFTER
|
||||
// setAbort (so the next-iteration drain sees a clean controller) and before
|
||||
// `runStep`: drop the step, end the turn `aborted`. closeStep balances the
|
||||
// already-appended step/start.
|
||||
if (handle.isCancelled()) {
|
||||
// Cancel landing in the step-start window: a synchronous
|
||||
// `agent/step-start` listener can cancel after the step is already open.
|
||||
// Check AFTER step/start append + emit and before `runStep`: drop the
|
||||
// step, end the turn accordingly. closeStep balances the already-appended
|
||||
// step/start.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = { kind: 'aborted', reason: handle.cancelReason() }
|
||||
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
|
||||
closeStep()
|
||||
break
|
||||
}
|
||||
|
||||
let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error }
|
||||
try {
|
||||
stepOutcome = await runStep(ctx, agent, turn, step, abort.signal)
|
||||
stepOutcome = await runStep(ctx, agent, turn, step, assembly, fullSystemPrompt, abort.signal)
|
||||
} catch (error: unknown) {
|
||||
stepOutcome = { error: toError(error) }
|
||||
} finally {
|
||||
@@ -549,22 +599,22 @@ function drainSteering(ctx: Context, agent: ReactLoopAgent, turn: number): boole
|
||||
return messages.length > 0
|
||||
}
|
||||
|
||||
/** One step: assemble request → stream model → record → execute tools. */
|
||||
/** One step: derive request from the (already pre-step-mutated) surface →
|
||||
* stream model → record → execute tools. The caller assembles the system prompt
|
||||
* and fires the `agent/pre-step` seam BEFORE opening the step, then passes the
|
||||
* resulting `assembly`/`system` here, so the surface this step derives from
|
||||
* already reflects any compaction. */
|
||||
async function runStep(
|
||||
ctx: Context,
|
||||
agent: ReactLoopAgent,
|
||||
turn: number,
|
||||
step: number,
|
||||
assembly: PromptAssembly,
|
||||
system: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ hadToolCalls: boolean; finish: FinishReason }> {
|
||||
const { session, options } = agent
|
||||
|
||||
// --- Request assembly ---
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
const system = [renderPrompt(assembly), options.systemPrompt ?? '']
|
||||
.filter(text => text.length > 0)
|
||||
.join('\n\n')
|
||||
|
||||
let request: GenerateOptions = {
|
||||
model: options.model ?? '',
|
||||
messages: session.deriveMessages(),
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
@@ -194,6 +194,73 @@ describe('Agent.cancel()', () => {
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from turn-start' }])
|
||||
})
|
||||
|
||||
it('cancel from a synchronous agent/step-start listener drops the step (post-step-start window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
// A step-start listener fires AFTER step/start is appended (and after the
|
||||
// pre-step seam), so cancelling there lands in the SECOND cancel check (the
|
||||
// one that must closeStep() to balance the already-open step) — distinct
|
||||
// from a turn-start cancel, which is caught before the step opens.
|
||||
let streamed = false
|
||||
ctx.on('agent/stream-chunk', () => { streamed = true })
|
||||
const dispose = ctx.on('agent/step-start', (subject) => {
|
||||
if (subject === agent) agent.cancel('from step-start')
|
||||
})
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
dispose()
|
||||
|
||||
// No step streamed, the turn ended aborted with the caller's reason, and the
|
||||
// log is balanced (the open step was closed by the cancel branch).
|
||||
expect(streamed).toBe(false)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from step-start' }])
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
|
||||
})
|
||||
|
||||
it('disposal from a synchronous agent/step-start listener closes the open step as disposed', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const handle = ctx.agents.create({
|
||||
agentId: AgentId('a-dispose-step-start'),
|
||||
sessionId: SessionId('dispose-step-start-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
const agent = handle.agent as ReactLoopAgent
|
||||
|
||||
let disposalDone: Promise<void> | undefined
|
||||
let streamed = false
|
||||
ctx.on('agent/stream-chunk', () => { streamed = true })
|
||||
ctx.on('agent/step-start', (subject) => {
|
||||
if (subject === agent) disposalDone = handle.dispose()
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await disposalDone
|
||||
await agent.done
|
||||
|
||||
expect(streamed).toBe(false)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
|
||||
})
|
||||
|
||||
it('cancel during the continuation window ends the turn aborted and runs no further step', async () => {
|
||||
// A continuation-waterfall listener cancels DURING the continuation decision
|
||||
// (the finished step's AbortController is already cleared), and votes to
|
||||
|
||||
@@ -320,6 +320,110 @@ describe('agent loop', () => {
|
||||
expect(adapter.requests[0]!.model).toBe('other-model')
|
||||
})
|
||||
|
||||
it('agent/pre-step fires once per step before the step is opened', async () => {
|
||||
// Two steps (a tool call, then a final text turn) → two model calls → two
|
||||
// pre-step fires, each carrying the assembled full system prompt, BEFORE
|
||||
// the step is opened and its request is derived (the request the adapter
|
||||
// sees reflects any surface state at fire time).
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', {}, 'calling echo'),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo', description: 'echo', parameters: {},
|
||||
async execute() { return [{ type: 'text', text: 'echoed' }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const fires: { turn: number; step: number; fullSystemPrompt: string }[] = []
|
||||
ctx.on('agent/pre-step', (subject, turn, step, fullSystemPrompt) => {
|
||||
if (subject === agent) fires.push({ turn, step, fullSystemPrompt })
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// One fire per step, in order, each with the assembled system prompt.
|
||||
expect(fires).toEqual([
|
||||
{ turn: 1, step: 1, fullSystemPrompt: '' },
|
||||
{ turn: 1, step: 2, fullSystemPrompt: '' },
|
||||
])
|
||||
})
|
||||
|
||||
it('agent/pre-step fires BEFORE the step it precedes opens (events land outside the step)', async () => {
|
||||
// A listener appending a surface node in pre-step lands it BEFORE step/start
|
||||
// in the log — proving the seam fires outside the step. The node is still in
|
||||
// the derived request for that step (derive happens after step/start).
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let injected = false
|
||||
ctx.on('agent/pre-step', (subject) => {
|
||||
if (subject === agent && !injected) {
|
||||
injected = true
|
||||
subject.session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'INJECTED-IN-PRE-STEP' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The adapter's request includes the node injected during pre-step (derive
|
||||
// reflects it).
|
||||
const text = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(text).toContain('INJECTED-IN-PRE-STEP')
|
||||
|
||||
// And the injected event sits BEFORE the first step/start in the log —
|
||||
// the seam fired outside the step.
|
||||
const events = agent.session.events
|
||||
const injectedSeq = events.find(e => e.type === 'context/message')!.seq
|
||||
const firstStepStartSeq = events.find(e => e.type === 'step/start')!.seq
|
||||
expect(injectedSeq).toBeLessThan(firstStepStartSeq)
|
||||
})
|
||||
|
||||
it('a throwing agent/pre-step listener ends the turn (error), not the loop', async () => {
|
||||
// The seam fires before step/start, so a throw escapes to runTurn's outer
|
||||
// catch: the not-yet-open step closes as a no-op, the failure surfaces via
|
||||
// agent/error, and the turn ends `error` (recorded on the durable turn/end).
|
||||
// The loop survives and a follow-up prompt still runs.
|
||||
const adapter = new MockAdapter([textResponse('second turn ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let throwOnce = true
|
||||
ctx.on('agent/pre-step', () => {
|
||||
if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') }
|
||||
})
|
||||
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
// The first turn failed at step 1 (no model call happened), surfaced via
|
||||
// agent/error, with the durable failure on turn/end.reason.
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]!.message).toContain('boom in pre-step')
|
||||
expect(adapter.requests.length).toBe(0)
|
||||
const firstTurnEnd = agent.session.events.find(e => e.type === 'turn/end')
|
||||
expect(firstTurnEnd?.type === 'turn/end' && firstTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1 })
|
||||
// The step opened-and-closed count stays balanced even though it never ran.
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
|
||||
|
||||
// The loop survived: a second prompt runs a normal completed turn.
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests.length).toBe(1)
|
||||
const lastTurnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
|
||||
expect(lastTurnEnd?.type === 'turn/end' && lastTurnEnd.data.reason).toEqual({ kind: 'completed' })
|
||||
})
|
||||
|
||||
it('cancel() mid-stream ends the turn with reason aborted', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -1047,3 +1047,275 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream
|
||||
expect(JSON.stringify(agent.session.deriveMessages())).toContain('injected')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => {
|
||||
// Block `system-prompt/assemble` on a promise. Start disposal (which
|
||||
// calls stop() synchronously, setting status=disposed), then release the
|
||||
// block. The loop must check isDisposed() after assembly and end the turn
|
||||
// `disposed` — no LLM call. Don't await fiber.dispose() before releasing
|
||||
// the blocker: the dispose chain awaits agent.done, which hangs until the
|
||||
// loop unblocks.
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
let releaseAssemble!: () => void
|
||||
const blocked = new Promise<void>(r => void (releaseAssemble = r))
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants, { freeze: false })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
// Blocking listener on the parent context (survives fiber disposal).
|
||||
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) {
|
||||
await blocked
|
||||
return next()
|
||||
})
|
||||
|
||||
let agent!: ReactLoopAgent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(AgentId('a-dispose-assemble'), { model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'go')
|
||||
// Give the loop time to enter the step and reach assemble().
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
// Start disposal — stop() sets status=disposed synchronously, then the
|
||||
// disposer's await agent.done hangs because the loop is blocked in the
|
||||
// waterfall. Do NOT await yet; release the blocker first.
|
||||
const disposalDone = fiber.dispose()
|
||||
|
||||
// Now release the blocked waterfall — the loop unblocks, checks
|
||||
// isDisposed(), and exits, which resolves agent.done and disposalDone.
|
||||
releaseAssemble()
|
||||
await disposalDone
|
||||
await agent.done
|
||||
unlisten()
|
||||
|
||||
const e = [...agent.session.events]
|
||||
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
|
||||
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
|
||||
const turnEnd = e.findLast(x => x.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
|
||||
// No step was opened, no LLM call was made.
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
||||
// agent/turn-end may not fire when disposal happens during assembly: the
|
||||
// fiber's disposer (stop→status=disposed) runs before closeTurn(true)'s
|
||||
// emit, and the LIFO chain disposes effects in reverse registration order.
|
||||
// The turn/end durable record is the one that matters.
|
||||
})
|
||||
|
||||
it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not appear')])
|
||||
let releaseAssemble!: () => void
|
||||
const blocker = new Promise<void>(r => void (releaseAssemble = r))
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants, { freeze: false })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, next) {
|
||||
await blocker
|
||||
return next()
|
||||
})
|
||||
|
||||
let agent!: ReactLoopAgent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(AgentId('a-cancel-assemble'), { model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
agent.cancel('user cancelled during assembly')
|
||||
|
||||
releaseAssemble()
|
||||
await waitForIdle(ctx, agent)
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
unlisten()
|
||||
|
||||
const e = [...agent.session.events]
|
||||
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
|
||||
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
|
||||
const turnEnd = e.findLast(x => x.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({
|
||||
kind: 'aborted',
|
||||
reason: 'user cancelled during assembly',
|
||||
})
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled during assembly' }])
|
||||
})
|
||||
|
||||
it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => {
|
||||
// Block the `agent/pre-step` serial seam on a promise we control, then
|
||||
// dispose the agent's fiber. When the block releases, the loop must see
|
||||
// isDisposed() at the post-seam check and end the turn disposed.
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
let releasePreStep!: () => void
|
||||
const blocker = new Promise<void>(r => void (releasePreStep = r))
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants, { freeze: false })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
ctx.on('agent/pre-step', async () => {
|
||||
await blocker
|
||||
})
|
||||
|
||||
let agent!: ReactLoopAgent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(AgentId('a-dispose-prestep'), { model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
// Start disposal, then release the block, then await disposal.
|
||||
const disposalDone = fiber.dispose()
|
||||
releasePreStep()
|
||||
await disposalDone
|
||||
await agent.done
|
||||
|
||||
// After the pre-step seam finishes, the post-seam cancel/dispose check
|
||||
// catches disposal. The step was never opened, no LLM call was made.
|
||||
const e = [...agent.session.events]
|
||||
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
|
||||
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
|
||||
const turnEnd = e.findLast(x => x.type === 'turn/end')
|
||||
// Disposal wins the post-seam check — reason is `disposed`.
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
||||
// agent/turn-end may not fire when disposal happens during pre-step: the
|
||||
// fiber's disposer runs before closeTurn(true)'s emit. The durable turn/end
|
||||
// is the authoritative record.
|
||||
})
|
||||
|
||||
it('cancel during agent/pre-step seam ends the turn aborted', { timeout: 15000 }, async () => {
|
||||
// Block `agent/pre-step`, then cancel() the agent. When the block releases,
|
||||
// the post-seam check catches cancellation and ends the turn aborted.
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
let releasePreStep!: () => void
|
||||
const blocker = new Promise<void>(r => void (releasePreStep = r))
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants, { freeze: false })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
ctx.on('agent/pre-step', async () => {
|
||||
await blocker
|
||||
})
|
||||
|
||||
let agent!: ReactLoopAgent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(AgentId('a-cancel-prestep'), { model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
agent.cancel('user cancelled')
|
||||
|
||||
releasePreStep()
|
||||
await waitForIdle(ctx, agent)
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
|
||||
const e = [...agent.session.events]
|
||||
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
|
||||
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
|
||||
const turnEnd = e.findLast(x => x.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: 'user cancelled' })
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled' }])
|
||||
})
|
||||
|
||||
it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => {
|
||||
// The key assertion from the original bug report: after disposal, no
|
||||
// assistant/chunk or assistant/message appears — the turn ends disposed
|
||||
// before any model interaction.
|
||||
const adapter = new MockAdapter([textResponse('should not appear')])
|
||||
let releaseAssemble!: () => void
|
||||
const blocker = new Promise<void>(r => void (releaseAssemble = r))
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants, { freeze: false })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
ctx.on('system-prompt/assemble', async function (_assembly, next) {
|
||||
await blocker
|
||||
return next()
|
||||
})
|
||||
|
||||
let agent!: ReactLoopAgent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(AgentId('a-dispose-no-leak'), { model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
const disposalDone = fiber.dispose()
|
||||
releaseAssemble()
|
||||
await disposalDone
|
||||
await agent.done
|
||||
|
||||
const e = [...agent.session.events]
|
||||
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
|
||||
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
|
||||
// The critical assertions: after disposal, the turn has no assistant
|
||||
// artifacts — the turn ended disposed before the model was invoked.
|
||||
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
// The durable turn/end reason is the authoritative record; agent/turn-end
|
||||
// may not fire when disposal interleaves with closeTurn(true)'s emit.
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,11 +37,12 @@ The full `agent/*` event taxonomy is declared via declaration merging in `dsh-ag
|
||||
- `agent/turn-start`, `agent/turn-end` (carries `TurnEndReason`)
|
||||
- `agent/step-start`, `agent/step-end`
|
||||
|
||||
#### Interception seams (waterfall)
|
||||
#### Interception seams
|
||||
|
||||
- `agent/request` — mutate `GenerateOptions` before the model call (hooks, compaction, model switching, tool filtering)
|
||||
- `agent/step-result` — post-process the assembled assistant message before tool dispatch (validates what the log records)
|
||||
- `agent/turn-continuation` — override the continue/stop decision (force-continue /loop, force-stop budget guard)
|
||||
- `agent/pre-step` (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after `turn/start` and before `step/start`, so a listener's appended events land outside the step.
|
||||
- `agent/request` (waterfall) — mutate `GenerateOptions` before the model call (hooks, model switching, tool filtering)
|
||||
- `agent/step-result` (waterfall) — post-process the assembled assistant message before tool dispatch (validates what the log records)
|
||||
- `agent/turn-continuation` (waterfall) — override the continue/stop decision (force-continue /loop, force-stop budget guard)
|
||||
|
||||
#### Streaming + tool (emit)
|
||||
|
||||
|
||||
@@ -179,11 +179,45 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/step-end'(agent: Agent, turn: number, step: number): void
|
||||
|
||||
// ---- interception seams (waterfall) ----
|
||||
// ---- step/request extension seams (serial + waterfall) ----
|
||||
/**
|
||||
* Awaited pre-step surface-mutation checkpoint, fired once per step AFTER
|
||||
* `turn/start` (and after the prior step closed) but BEFORE this step's
|
||||
* `step/start` — so anything a listener appends lands OUTSIDE the step,
|
||||
* between `turn/start`/`step/end` and the upcoming `step/start`. `step` is
|
||||
* the number of the step about to start. The loop awaits
|
||||
* `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then
|
||||
* opens the step and derives the request history ONCE from whatever the
|
||||
* surface now holds. This is where compaction belongs: it mutates the session
|
||||
* surface in place (shadowing an older range with a summary node) with its
|
||||
* log-only `compact/*` records cleanly outside any step, and the single
|
||||
* subsequent derive reflects the mutation — so there is no double-derive and
|
||||
* no listener can see (or be expected to act on) an assembled `messages`
|
||||
* array that does not exist yet.
|
||||
*
|
||||
* Serial (awaited in registration order), not a waterfall: a listener
|
||||
* mutates the surface as a side effect; there is nothing to transform, but
|
||||
* the loop must wait for the mutation to complete before opening the step
|
||||
* and deriving. Cordis `serial` bails early if a listener returns a bail
|
||||
* value; this event is typed and documented as `void`, so listeners must not
|
||||
* return a semantic veto value. `fullSystemPrompt` is the assembled prompt a
|
||||
* listener needs to measure pressure (the system prompt counts toward the
|
||||
* budget). `signal` cancels any in-flight work a listener starts (e.g. a
|
||||
* summarization model call).
|
||||
* @mode serial
|
||||
*/
|
||||
// TODO: `fullSystemPrompt` is a smell on a generic per-step seam — compaction
|
||||
// is its only consumer, so a wide event carries a string just one listener
|
||||
// reads. Revisit if no second consumer appears: e.g. hand listeners a lazy
|
||||
// prompt provider, or move token-pressure measurement behind a
|
||||
// compaction-specific seam instead of the shared pre-step checkpoint.
|
||||
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise<void> | void
|
||||
/**
|
||||
* Waterfall: mutate the fully-assembled {@link GenerateOptions} before the
|
||||
* model call (hooks, compaction, model switching, tool filtering, …). Call
|
||||
* `next()` to delegate, or return without it to short-circuit.
|
||||
* model call (hooks, model switching, tool filtering, …). Call `next()` to
|
||||
* delegate, or return without it to short-circuit. For surface mutation that
|
||||
* must precede history derivation (compaction), use {@link agent/pre-step}
|
||||
* instead — by the time this fires, `options.messages` is already derived.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise<GenerateOptions>): Promise<GenerateOptions>
|
||||
|
||||
@@ -57,7 +57,7 @@ Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types
|
||||
|
||||
Every `SessionEvent` carries two optional top-level fields (structural metadata):
|
||||
|
||||
- `sourceEventSeqs?: number[]` — seq numbers of provenance sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed nodes behind a compaction marker).
|
||||
- `sourceEventSeqs?: number[]` — seq numbers of provenance sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed nodes behind a compaction replace node).
|
||||
- `surfaceOp?: SurfaceOp` — how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors).
|
||||
|
||||
### Metadata types (`types.ts`)
|
||||
@@ -68,7 +68,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
|
||||
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
|
||||
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME invariants `append` enforces — including that every surface-eligible event (`SurfaceEventType`) carries a `surfaceOp` marker — so a marker-less message event is rejected at construction rather than silently vanishing from `deriveMessages()` (the surface is the sole derivation path) on resume.
|
||||
- Compaction: a future plugin appends a new event with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes.
|
||||
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint.
|
||||
|
||||
### What is NOT here (TODO)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export { isJsonValue } from './json.ts'
|
||||
export { interruptedTurnClosers } from './repair.ts'
|
||||
export type { SurfaceNode } from './surface.ts'
|
||||
export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { isToolPairingBalanced } from './tool-pairing.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
|
||||
100
packages/core/session/src/tool-pairing.ts
Normal file
100
packages/core/session/src/tool-pairing.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tool-pairing balance over a session's SURFACE: is a given cut point in the
|
||||
* surface a safe edge for a collapsed region (e.g. compaction)?
|
||||
*
|
||||
* The invariant a consumer needs: a collapsed region must never separate an
|
||||
* `assistant/message`'s `tool-call` blocks from their answering `tool/result`s
|
||||
* — that would leave the rehydrated transcript with a dangling tool-call or an
|
||||
* orphaned tool-result, which every provider rejects. (This is the
|
||||
* compaction-time mirror of the crash-recovery imbalance that
|
||||
* {@link interruptedTurnClosers} repairs on load.) Steps were once used as a
|
||||
* proxy for this bracketing, but a compaction REWRITES the surface — it lands a
|
||||
* replacement node at a high log seq whose SURFACE position is the head — so a
|
||||
* scan over the LOG's `step/*` markers mis-reads such a node's neighbours. The
|
||||
* pairing the invariant actually protects lives in the surface nodes' own
|
||||
* content (a `tool-call` block's id, a `tool/result`'s `callId`), which travels
|
||||
* with the node through any reshaping, so alignment is decided over the surface
|
||||
* directly.
|
||||
*
|
||||
* A **cut** is a gap between two adjacent surface nodes (named by the node it
|
||||
* sits immediately before), or the after-tail gap (`null`). Walking the surface
|
||||
* head→tail and assigning each node a delta — `+1` per `tool-call` block on an
|
||||
* `assistant/message`, `-1` per `tool/result`, `0` otherwise — the depth at a
|
||||
* cut is the number of still-unanswered tool calls before it. A cut is
|
||||
* **balanced** when that depth is `0`. A region `[start..end]` is safe to
|
||||
* collapse iff BOTH its edges are balanced cuts: the cut before `start` and the
|
||||
* cut after `end`. Nodes that belong to no step (a pre-step `user/message`, an
|
||||
* inter-step `steering/message`, an injection `context/message`) carry no
|
||||
* pairing, contribute `0`, and so are free boundaries — exactly as before, but
|
||||
* now as a consequence of the balance rather than a special case. An open
|
||||
* trailing step (an assistant whose `tool/result`s have not landed yet) keeps
|
||||
* the depth positive through the tail, so no cut inside it is balanced — the
|
||||
* old explicit open-step check falls out of the same counter.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session/tool-pairing
|
||||
*/
|
||||
|
||||
import type { SessionEvent } from './types.ts'
|
||||
import type { SurfaceNode } from './surface.ts'
|
||||
|
||||
/**
|
||||
* The tool-pairing delta of a surface node: how it shifts the count of
|
||||
* unanswered tool calls. An `assistant/message` opens one bracket per
|
||||
* `tool-call` block; a `tool/result` closes one; every other surface node
|
||||
* (`user/message`, `context/message`, `steering/message`, a usage-only
|
||||
* `assistant/message` with no tool-call blocks) is pairing-neutral.
|
||||
*/
|
||||
function nodeDelta(event: SessionEvent): number {
|
||||
switch (event.type) {
|
||||
case 'assistant/message':
|
||||
return event.data.content.filter(block => block.type === 'tool-call').length
|
||||
case 'tool/result':
|
||||
return -1
|
||||
// Non-pairing surface nodes and every non-surface event contribute nothing.
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the surface prefix ending at the given cut has BALANCED tool-call /
|
||||
* tool-result brackets — i.e. every `tool-call` block on the surface before the
|
||||
* cut has its answering `tool/result` before the cut too, so the cut is a safe
|
||||
* edge for a collapsed region (it cannot split an assistant↔result pair).
|
||||
*
|
||||
* `nodes` is the surface linked list in head→tail order (e.g.
|
||||
* `session.surface.nodes`); `events` is the session log, used to look each
|
||||
* node's event up by `seq`. `beforeSeq` names the cut by the surface node it
|
||||
* sits immediately before; the after-tail cut (the whole surface) is `null`,
|
||||
* as is any `beforeSeq` not present on the surface.
|
||||
*
|
||||
* A region `[start..end]` is collapsible iff both edges are balanced cuts: call
|
||||
* `isToolPairingBalanced(nodes, events, start)` for the cut before `start`, and
|
||||
* `isToolPairingBalanced(nodes, events, after)` — where `after` is `end`'s
|
||||
* surface successor (`SurfaceNode.next`), or `null` when `end` is the tail —
|
||||
* for the cut after `end`.
|
||||
*
|
||||
* @throws if the surface prefix drives the unanswered-call depth negative — a
|
||||
* `tool/result` with no preceding open `tool-call` on the surface. That is a
|
||||
* corrupt surface (a structural invariant violation), surfaced loudly here
|
||||
* rather than silently mis-classifying a boundary.
|
||||
*/
|
||||
export function isToolPairingBalanced(
|
||||
nodes: readonly SurfaceNode[],
|
||||
events: readonly SessionEvent[],
|
||||
beforeSeq: number | null,
|
||||
): boolean {
|
||||
let depth = 0
|
||||
for (const node of nodes) {
|
||||
if (node.seq === beforeSeq) return depth === 0
|
||||
// node.seq is a surface-node seq, always a valid log index by construction.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
depth += nodeDelta(events[node.seq]!)
|
||||
if (depth < 0) {
|
||||
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
|
||||
}
|
||||
}
|
||||
// Reached the after-tail cut (beforeSeq === null, or a seq not on the
|
||||
// surface): the whole-surface prefix is balanced iff depth returned to 0.
|
||||
return depth === 0
|
||||
}
|
||||
@@ -174,7 +174,8 @@ export interface TodoItem {
|
||||
* same events; trace/telemetry = subscribe to the log.
|
||||
*
|
||||
* Merge-extensible: plugins declare extra event types via declaration merging
|
||||
* (e.g. a compaction plugin adds `'compaction/marker'`).
|
||||
* (e.g. the compaction plugin adds `'compact/start'`, `'compact/summary'`,
|
||||
* `'compact/end'`).
|
||||
*
|
||||
* Durability contract (what a persistence backend relies on): the durable log
|
||||
* persists every event verbatim, INCLUDING `assistant/chunk` — `seq` must stay
|
||||
@@ -311,7 +312,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
/**
|
||||
* Seq numbers of events that are provenance sources of this event
|
||||
* (e.g. the `assistant/chunk` seqs that built an `assistant/message`,
|
||||
* or the surface nodes shadowed by a compaction marker).
|
||||
* or the surface nodes shadowed by a compaction replace node).
|
||||
*/
|
||||
sourceEventSeqs?: number[]
|
||||
/** How this event entered the surface; absent for non-surface events. */
|
||||
|
||||
@@ -278,6 +278,23 @@ describe('Session.append surface opts', () => {
|
||||
// The string 'append' is a primitive — identity-preserving is fine.
|
||||
expect(event.surfaceOp).toBe('append')
|
||||
})
|
||||
|
||||
it('isSurfaceEvent rejects a surface-eligible type missing its surfaceOp marker', () => {
|
||||
// A raw event (not built via append, which mandates the marker) of a
|
||||
// surface-eligible type but with no surfaceOp must NOT narrow to a
|
||||
// SurfaceEvent — it would otherwise be silently dropped from the surface.
|
||||
const noMarker: SessionEvent = {
|
||||
type: 'user/message', seq: 0, time: 1,
|
||||
data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
|
||||
}
|
||||
expect(isSurfaceEvent(noMarker)).toBe(false)
|
||||
// A non-surface type is rejected too (the type gate).
|
||||
const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }
|
||||
expect(isSurfaceEvent(boundary)).toBe(false)
|
||||
// A properly-marked surface event narrows.
|
||||
const marked = { ...noMarker, surfaceOp: 'append' } as SurfaceEvent
|
||||
expect(isSurfaceEvent(marked)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('surface type guards', () => {
|
||||
|
||||
314
packages/core/session/tests/tool-pairing.spec.ts
Normal file
314
packages/core/session/tests/tool-pairing.spec.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts'
|
||||
import type { SessionEvent, SurfaceNode } from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* Unit coverage for the tool-pairing balance check. It decides whether a CUT in
|
||||
* the surface (a gap before a given surface node, or the after-tail gap) is a
|
||||
* safe edge for a collapsed region (compaction): a region must never split an
|
||||
* `assistant/message`'s tool-calls from their `tool/result`s. A cut is balanced
|
||||
* when no unanswered tool-call sits before it on the surface. Nodes belonging to
|
||||
* no step (pre-step user message, inter-step steering, injection context) are
|
||||
* pairing-neutral, so their cuts are free boundaries.
|
||||
*
|
||||
* The fixtures are built through a real {@link Session} so the surface linked
|
||||
* list is derived exactly as production does — including the non-monotonic
|
||||
* surface a `replace` op leaves (a compaction checkpoint at a high log seq
|
||||
* sitting at the surface head), which is the case the abandoned log-position
|
||||
* scan mis-classified.
|
||||
*
|
||||
* Builders mirror the agent loop's real append order: queued user messages land
|
||||
* BEFORE `step/start`; within a step the order is `assistant/message` then
|
||||
* `tool/result`(s); injection turns are a bare `turn/start → context/message →
|
||||
* turn/end` with no step.
|
||||
*/
|
||||
|
||||
const SURFACE = { surfaceOp: 'append' as const }
|
||||
|
||||
/** Surface nodes + log for a session, the two args the balance check takes. */
|
||||
function surfaceOf(session: Session): { nodes: readonly SurfaceNode[]; events: readonly SessionEvent[] } {
|
||||
return { nodes: session.surface.nodes, events: session.events }
|
||||
}
|
||||
|
||||
/** The cut BEFORE the surface node at `seq` is balanced (safe region start). */
|
||||
function startBalanced(session: Session, seq: number): boolean {
|
||||
const { nodes, events } = surfaceOf(session)
|
||||
return isToolPairingBalanced(nodes, events, seq)
|
||||
}
|
||||
|
||||
/** The cut AFTER the surface node at `seq` is balanced (safe region end). */
|
||||
function endBalanced(session: Session, seq: number): boolean {
|
||||
const { nodes, events } = surfaceOf(session)
|
||||
const node = nodes.find(n => n.seq === seq)
|
||||
if (!node) throw new Error(`seq ${seq} is not a surface node`)
|
||||
return isToolPairingBalanced(nodes, events, node.next)
|
||||
}
|
||||
|
||||
/** Surface seq of the nth (0-based) event of a given type. */
|
||||
function seqOf(s: Session, type: SessionEvent['type'], nth = 0): number {
|
||||
return s.events.filter(e => e.type === type)[nth]!.seq
|
||||
}
|
||||
|
||||
/** A closed turn with one closed step holding an assistant + its tool result. */
|
||||
function toolStepSession(): Session {
|
||||
const s = new Session(SessionId('tool-step'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, SURFACE)
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [
|
||||
{ type: 'text', text: 'calling' },
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' },
|
||||
],
|
||||
}, SURFACE)
|
||||
s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' })
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return s
|
||||
}
|
||||
|
||||
describe('isToolPairingBalanced — region START (cut before a node)', () => {
|
||||
it('is true for a pre-step user/message (belongs to no step)', () => {
|
||||
const s = toolStepSession()
|
||||
expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for the first surface node of a step (the assistant/message)', () => {
|
||||
// The cut before the assistant is balanced — nothing unanswered precedes it.
|
||||
const s = toolStepSession()
|
||||
expect(startBalanced(s, seqOf(s, 'assistant/message'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a tool/result whose assistant/message precedes it in the same step', () => {
|
||||
// The cut before the tool/result has one unanswered tool-call (the
|
||||
// assistant's) → starting the region here would orphan that call.
|
||||
const s = toolStepSession()
|
||||
expect(startBalanced(s, seqOf(s, 'tool/result'))).toBe(false)
|
||||
})
|
||||
|
||||
it('is true at the surface head (nothing precedes)', () => {
|
||||
const s = new Session(SessionId('lone'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE)
|
||||
expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — region END (cut after a node)', () => {
|
||||
it('is true for the last surface node of a closed step (the tool/result)', () => {
|
||||
// After the tool/result the assistant's single call is answered → balanced.
|
||||
const s = toolStepSession()
|
||||
expect(endBalanced(s, seqOf(s, 'tool/result'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for an assistant/message with a later tool/result in the same step', () => {
|
||||
// After the assistant its tool-call is still unanswered → ending here strands
|
||||
// the result.
|
||||
const s = toolStepSession()
|
||||
expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false)
|
||||
})
|
||||
|
||||
it('is true for a pre-step user/message', () => {
|
||||
const s = toolStepSession()
|
||||
expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false at the tail when the node is inside an open (unclosed) step', () => {
|
||||
// step/start then an assistant tool-call, but no tool/result yet (mid-flight).
|
||||
// The after-tail cut still has one unanswered call → not balanced.
|
||||
const s = new Session(SessionId('open-step'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false)
|
||||
})
|
||||
|
||||
it('is true at the tail when the node is a trailing inter-step node (step already closed)', () => {
|
||||
// A steering message appended after step/end, at the tail. The prior step's
|
||||
// pair is balanced and steering is neutral → the after-tail cut is balanced.
|
||||
const s = new Session(SessionId('trailing-steer'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, SURFACE)
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 's' }], source: { kind: 'user' } }, SURFACE)
|
||||
expect(endBalanced(s, seqOf(s, 'steering/message'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is true at the tail when no step ever opened', () => {
|
||||
const s = new Session(SessionId('no-step'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE)
|
||||
expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — multiple tool calls in one assistant message', () => {
|
||||
// An assistant message with two tool-calls needs BOTH results before the cut
|
||||
// after it is balanced — depth +2, then -1, -1.
|
||||
function twoCallStep(): Session {
|
||||
const s = new Session(SessionId('two-call'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'a', arguments: '{}' },
|
||||
{ type: 'tool-call', id: CallId('c2'), name: 'b', arguments: '{}' },
|
||||
],
|
||||
}, SURFACE)
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: '1' }], isError: false }, SURFACE)
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c2'), content: [{ type: 'text', text: '2' }], isError: false }, SURFACE)
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return s
|
||||
}
|
||||
|
||||
it('is unbalanced after the first of two results (one call still open)', () => {
|
||||
const s = twoCallStep()
|
||||
expect(endBalanced(s, seqOf(s, 'tool/result', 0))).toBe(false)
|
||||
})
|
||||
|
||||
it('is balanced after the second result (both calls answered)', () => {
|
||||
const s = twoCallStep()
|
||||
expect(endBalanced(s, seqOf(s, 'tool/result', 1))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — a mid-step injection context/message', () => {
|
||||
// A background task-done inject() lands a context/message INSIDE an open step,
|
||||
// between the assistant (with a tool-call) and its tool/result. It is
|
||||
// pairing-neutral, so the cut on EITHER side of it is unbalanced (the call is
|
||||
// still open across it) — it is NOT a free boundary in this position.
|
||||
function midStepInjection(): Session {
|
||||
const s = new Session(SessionId('mid-inject'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
s.append('context/message', { content: [{ type: 'text', text: 'bg task done' }], source: { kind: 'plugin', plugin: 'tool-bash' } }, SURFACE)
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return s
|
||||
}
|
||||
|
||||
it('start cut before the mid-step context/message is unbalanced (call still open)', () => {
|
||||
const s = midStepInjection()
|
||||
expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(false)
|
||||
})
|
||||
|
||||
it('end cut after the mid-step context/message is unbalanced (call still open)', () => {
|
||||
const s = midStepInjection()
|
||||
expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced on an injection turn (no step)', () => {
|
||||
// An idle inject() wraps a context/message in a bare turn/start →
|
||||
// context/message → turn/end with NO step. The context node is a free boundary
|
||||
// both ways (pairing-neutral, nothing open around it).
|
||||
function injectionSession(): Session {
|
||||
const s = new Session(SessionId('injection'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } })
|
||||
s.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, SURFACE)
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return s
|
||||
}
|
||||
|
||||
it('start: balanced', () => {
|
||||
const s = injectionSession()
|
||||
expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(true)
|
||||
})
|
||||
|
||||
it('end: balanced', () => {
|
||||
const s = injectionSession()
|
||||
expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => {
|
||||
// The case the log-position scan got wrong. After a compaction, a replacement
|
||||
// user/message lands at a HIGH log seq but sits at the SURFACE head, beside
|
||||
// the still-open step whose events follow it in the log. It carries no
|
||||
// tool-call/result pair (just summarized prose), so it must be a balanced cut
|
||||
// on BOTH sides regardless of its log neighbours.
|
||||
function checkpointHeadedSession(): Session {
|
||||
const s = new Session(SessionId('checkpoint'))
|
||||
// A closed turn with a tool step → surface [u1, asst(call), result].
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'u1' }], source: { kind: 'user' } }, SURFACE)
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// An OPEN turn whose step is in progress (loop fires compaction here).
|
||||
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 2, step: 1 })
|
||||
// Compaction replaces the whole turn-1 surface ([u1, asst, result]) with one
|
||||
// summary user/message — appended now, so it carries a high log seq.
|
||||
const u1 = seqOf(s, 'user/message')
|
||||
const result = s.events.find(e => e.type === 'tool/result')!.seq
|
||||
s.append('user/message', {
|
||||
content: [{ type: 'text', text: 'CHECKPOINT' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, { surfaceOp: { op: 'replace', start: u1, end: result } })
|
||||
// The step's own assistant/message lands AFTER the checkpoint in the log,
|
||||
// still inside the open step.
|
||||
s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'a2' }] }, SURFACE)
|
||||
return s
|
||||
}
|
||||
|
||||
it('the head checkpoint sits at the surface head while a later surface node follows it in the log', () => {
|
||||
const s = checkpointHeadedSession()
|
||||
const nodes = s.surface.nodes
|
||||
const checkpointSeq = nodes[0]!.seq
|
||||
// The checkpoint heads the surface, yet a surface node (the open step's
|
||||
// assistant) follows it in LOG order — the exact split between surface
|
||||
// position and log position that the log-position scan tripped on.
|
||||
const laterSurfaceInLog = s.events.find(
|
||||
e => e.seq > checkpointSeq && nodes.some(n => n.seq === e.seq),
|
||||
)
|
||||
expect(laterSurfaceInLog).toBeDefined()
|
||||
expect(nodes[0]!.seq).toBe(checkpointSeq)
|
||||
})
|
||||
|
||||
it('start cut before the head checkpoint is balanced (it is the head)', () => {
|
||||
const s = checkpointHeadedSession()
|
||||
expect(startBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
|
||||
})
|
||||
|
||||
it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => {
|
||||
// This is the exact assertion the log-position scan failed: the forward log
|
||||
// scan from the checkpoint reached the open step's assistant/message and
|
||||
// wrongly reported mid-step. The surface balance sees a neutral node whose
|
||||
// following cut closes no open call.
|
||||
const s = checkpointHeadedSession()
|
||||
expect(endBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — corrupt surface guard', () => {
|
||||
it('throws when a tool/result has no preceding tool-call (depth goes negative)', () => {
|
||||
// A surface that opens with a tool/result (no assistant call before it) is
|
||||
// structurally corrupt — surfaced loudly rather than mis-classified.
|
||||
const s = new Session(SessionId('corrupt'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'x' }], isError: false }, SURFACE)
|
||||
const { nodes, events } = surfaceOf(s)
|
||||
expect(() => isToolPairingBalanced(nodes, events, null)).toThrow(/no matching tool-call/)
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
@@ -31,6 +32,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { dirname, join, resolve } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { Context, Service } from 'cordis'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
|
||||
@@ -249,18 +250,13 @@ export class SkillService extends Service {
|
||||
}
|
||||
|
||||
async function writeSystemSkills(systemRoot: string, ctx: Context): Promise<void> {
|
||||
await mkdir(systemRoot, { recursive: true })
|
||||
await Promise.all(SYSTEM_SKILLS.map(async (skill) => {
|
||||
const dir = join(systemRoot, skill.name)
|
||||
const file = join(dir, 'SKILL.md')
|
||||
try {
|
||||
await access(file)
|
||||
if (await skillFileExists(ctx, file)) {
|
||||
return
|
||||
} catch {
|
||||
// Expected first-run path: the bundled system skill has not been installed.
|
||||
}
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(file, renderSkillFile(skill))
|
||||
await writeSkillText(ctx, file, renderSkillFile(skill))
|
||||
ctx.logger.debug(`installed system skill ${skill.name} at ${file}`)
|
||||
}))
|
||||
}
|
||||
@@ -300,10 +296,8 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillDefinit
|
||||
}
|
||||
|
||||
async function parseSkillFile(path: string, directory: string, source: SkillSource, ctx: Context): Promise<SkillDefinition | undefined> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(path, 'utf8')
|
||||
} catch {
|
||||
const raw = await readSkillText(ctx, path)
|
||||
if (raw === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = parseFrontmatter(raw)
|
||||
@@ -334,6 +328,63 @@ async function parseSkillFile(path: string, directory: string, source: SkillSour
|
||||
}
|
||||
}
|
||||
|
||||
function optionalFileSystem(ctx: Context): FileSystem | undefined {
|
||||
return ctx.get('fs')
|
||||
}
|
||||
|
||||
async function skillFileExists(ctx: Context, path: string): Promise<boolean> {
|
||||
const fs = optionalFileSystem(ctx)
|
||||
if (fs !== undefined) {
|
||||
const target = await fs.resolve(path)
|
||||
return await fs.stat(target) !== undefined
|
||||
}
|
||||
try {
|
||||
await access(path)
|
||||
return true
|
||||
} catch {
|
||||
// Expected first-run path: the bundled system skill has not been installed.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSkillText(ctx: Context, path: string, content: string): Promise<void> {
|
||||
const fs = optionalFileSystem(ctx)
|
||||
if (fs !== undefined) {
|
||||
await fs.writeText(await fs.resolve(path), content)
|
||||
return
|
||||
}
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, content)
|
||||
}
|
||||
|
||||
async function readSkillText(ctx: Context, path: string): Promise<string | undefined> {
|
||||
const fs = optionalFileSystem(ctx)
|
||||
if (fs !== undefined) {
|
||||
return await readSkillTextFromFileSystem(ctx, fs, path)
|
||||
}
|
||||
try {
|
||||
return await readFile(path, 'utf8')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function readSkillTextFromFileSystem(ctx: Context, fs: FileSystem, path: string): Promise<string | undefined> {
|
||||
const target = await fs.resolve(path)
|
||||
const info = await fs.stat(target)
|
||||
if (info === undefined || info.type !== 'file') return undefined
|
||||
try {
|
||||
return await fs.readText(target)
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`skill file ${path} ignored: ${fsReadErrorMessage(target, error)}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function fsReadErrorMessage(target: FsTarget, error: unknown): string {
|
||||
return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`
|
||||
}
|
||||
|
||||
function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
|
||||
if (!raw.startsWith('---\n')) return undefined
|
||||
const end = raw.indexOf('\n---', 4)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
|
||||
async function tempDir(name: string): Promise<string> {
|
||||
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
|
||||
@@ -194,6 +195,24 @@ describe('SkillService', () => {
|
||||
expect(await readFile(join(home, '.dsh/skills/.system/dsh-skill-creator/SKILL.md'), 'utf8')).toContain('dsh-skill-creator')
|
||||
})
|
||||
|
||||
it('uses the filesystem service when installing bundled system skills', async () => {
|
||||
const home = await tempDir('skill-install-fs')
|
||||
const existing = join(home, '.dsh/skills/.system/dsh-plugin-creator/SKILL.md')
|
||||
await mkdir(join(home, '.dsh/skills/.system/dsh-plugin-creator'), { recursive: true })
|
||||
await writeFile(existing, '---\nname: dsh-plugin-creator\ndescription: Existing system skill\n---\n\nExisting body.\n')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalFileSystem, { cwd: home })
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description])).toEqual([
|
||||
['dsh-plugin-creator', 'Existing system skill'],
|
||||
['dsh-skill-creator', 'Create or update DeepSeek Harness SKILL.md instructions.'],
|
||||
])
|
||||
expect(await readFile(existing, 'utf8')).toContain('Existing body.')
|
||||
expect(await readFile(join(home, '.dsh/skills/.system/dsh-skill-creator/SKILL.md'), 'utf8')).toContain('dsh-skill-creator')
|
||||
})
|
||||
|
||||
it('renders bundled system skill files with and without routing metadata', async () => {
|
||||
const home = await tempDir('skill-install-render')
|
||||
|
||||
@@ -205,6 +224,26 @@ describe('SkillService', () => {
|
||||
expect(await readFile(join(home, '.dsh/skills/.system/dsh-skill-creator/SKILL.md'), 'utf8')).toContain('whenToUse:')
|
||||
})
|
||||
|
||||
it('uses the filesystem service for skill file reads when it is available', async () => {
|
||||
const home = await tempDir('skill-read-fs')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await writeFlatSkill(root, 'text-skill', 'Text skill', 'Text body.')
|
||||
await mkdir(join(root, 'empty-dir'), { recursive: true })
|
||||
await mkdir(join(root, 'directory-skill/SKILL.md'), { recursive: true })
|
||||
await writeFile(join(root, 'binary-skill.md'), Buffer.concat([
|
||||
Buffer.from('---\nname: binary-skill\ndescription: Binary skill\n---\n\n'),
|
||||
Buffer.from([0xff]),
|
||||
Buffer.from('\n'),
|
||||
]))
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalFileSystem, { cwd: home })
|
||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['text-skill'])
|
||||
expect(await ctx.skills.get('binary-skill')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('degrades when bundled system skill installation fails', async () => {
|
||||
const home = await tempDir('skill-install-fail')
|
||||
await writeFile(join(home, '.dsh'), 'not a directory')
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../fs/fs" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../agent" }
|
||||
]
|
||||
|
||||
@@ -34,4 +34,4 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` via decla
|
||||
### What is NOT here
|
||||
|
||||
- Any hardcoded prompt text — every section comes from plugins.
|
||||
- Prompt compaction (belongs on the `agent/request` seam in `dsh-agent`).
|
||||
- Prompt compaction (belongs on the `agent/pre-step` seam in `dsh-agent`).
|
||||
|
||||
@@ -8,7 +8,7 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber.
|
||||
- `ctx.tools.get(name: string): ToolDefinition | undefined`
|
||||
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions).
|
||||
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog/tools.md](../../../docs/tool-catalog/tools.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
||||
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/execute` waterfall.
|
||||
|
||||
### Injected services
|
||||
@@ -72,7 +72,7 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an
|
||||
|
||||
A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods:
|
||||
|
||||
- `presentCall(args): ToolCallPresentation | undefined` — the PENDING state: a human-readable `title` (always-visible label), an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a shell command as a string, NOT the whole args object), an optional `content` (UI content shown alongside the title/card — e.g. a bash `description` as a text block above the terminal card), and an optional `terminal` (a neutral `{ cwd? }` asking a capable UI to render this call as a TERMINAL, e.g. for `bash`).
|
||||
- `presentCall(args): ToolCallPresentation | undefined` — the PENDING state: a human-readable `title` (always-visible label), an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a shell command as a string, NOT the whole args object), an optional `content` (UI content shown alongside the title/card — e.g. a bash `description` as a text block above the terminal card), an optional `locations` (`{ path, line? }[]` — the files this call reads/modifies, so a capable UI can follow along / jump to them; the ACP bridge forwards them as `tool_call.locations`), and an optional `terminal` (a neutral `{ cwd? }` asking a capable UI to render this call as a TERMINAL, e.g. for `bash`).
|
||||
- `presentResult(args, result): ToolResultPresentation | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError }` result: an optional replacement `title`, reformatted `content` (e.g. wrap command output in a fenced ` ```console ` block — a UI-only affordance that must NOT appear in the model-facing `execute` result), and an optional `terminal` (the `{ output?, exitCode?, signal? }` for a terminal-rendered call). The `ToolTerminal` shape is provider-neutral; a UI bridge (the ACP bridge) maps it to a terminal card (with an exit-status pill) and a UI that can't ignores it and uses `content`.
|
||||
|
||||
Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. With `defineTool`, `args` is the typed `InferArgs<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The shapes are provider-neutral — the ACP bridge (`dsh-acp`) maps them to ACP `tool_call`/`tool_call_update` wire fields, and `dsh-tool-bash` is the reference implementation.
|
||||
|
||||
@@ -109,6 +109,16 @@ export interface ToolCallPresentation {
|
||||
* {@link terminal} block (if any) as a terminal card.
|
||||
*/
|
||||
content?: ContentBlock[]
|
||||
/**
|
||||
* Files this call reads or modifies, so a capable UI can "follow along" —
|
||||
* highlight or jump to the file (and line) as the tool runs. Provider-neutral
|
||||
* `{ path, line? }` pairs; a UI bridge maps them to its own affordance (the ACP
|
||||
* bridge forwards them as `tool_call.locations`). `path` is what the tool
|
||||
* operated on (the model-facing path); `line` is an optional 1-based line to
|
||||
* focus (e.g. a read's offset). Omit for a call that touches no file (e.g.
|
||||
* `bash`).
|
||||
*/
|
||||
locations?: { path: string; line?: number }[]
|
||||
/**
|
||||
* Ask a capable UI to render this call as a TERMINAL (a command running in a
|
||||
* working directory), not a generic tool card — set by a tool whose call IS a
|
||||
|
||||
117
packages/core/tools/tests/gen-tool-catalog.spec.ts
Normal file
117
packages/core/tools/tests/gen-tool-catalog.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Guarantee tests for the tool-schema catalog generator
|
||||
* (`scripts/gen-tool-catalog.ts`).
|
||||
*
|
||||
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
|
||||
* the freshness half is exercised by `pnpm run verify-tool-catalog` in CI. What
|
||||
* a freshness diff CANNOT prove is (a) that BOOTING the tool plugins yields the
|
||||
* shipped schema — the whole reason this generator boots instead of parsing
|
||||
* source (a runtime-spread enum resolves to its literal members) — and (b) that
|
||||
* the completeness guard REJECTS a tool package missing from the boot manifest,
|
||||
* the property that replaces the AST pass's "nothing silently omitted". These
|
||||
* tests drive the exported `collectToolCatalog` / `assertManifestComplete` /
|
||||
* `render` directly, mirroring the negative-path style of the cordis-catalog
|
||||
* generator tests.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertManifestComplete,
|
||||
collectToolCatalog,
|
||||
render,
|
||||
type ToolCatalog,
|
||||
} from '../../../../scripts/gen-tool-catalog.ts'
|
||||
|
||||
/** JSON Schema shape enough to reach the values AST extraction can't. */
|
||||
interface JsonSchema {
|
||||
type: string
|
||||
properties?: Record<string, JsonSchema>
|
||||
items?: JsonSchema
|
||||
enum?: string[]
|
||||
required?: string[]
|
||||
}
|
||||
|
||||
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(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'skill', 'subagent', 'todo_write', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
expect((schema.parameters as unknown as JsonSchema).type).toBe('object')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves a runtime-spread enum to its literal members (the payoff over AST)', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const todo = catalog
|
||||
.flatMap(entry => entry.schemas)
|
||||
.find(s => s.name === 'todo_write')
|
||||
// `todo-todo` writes `enum: [...STATUSES]` — a source AST would see the
|
||||
// spread, not the values. Booting yields the shipped enum literals.
|
||||
const status = (((todo?.parameters as unknown as JsonSchema).properties?.todos)?.items)?.properties?.status
|
||||
expect(status?.enum).toEqual(['pending', 'in_progress', 'completed'])
|
||||
})
|
||||
|
||||
it('attributes each package with a source pointer that names its index', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const bash = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-bash')
|
||||
expect(bash?.source).toBe('packages/bash/tool-bash/src/index.ts')
|
||||
})
|
||||
|
||||
it('records the shipped `subagent_fork` alias in a note (config-driven tool name)', async () => {
|
||||
// `tool-subagent`'s registered name is the load-time `toolName` config, so
|
||||
// the shipped agents surface this one package as both `subagent` and
|
||||
// `subagent_fork`. Booting yields only the default name; the note is how a
|
||||
// reader learns the fork alias the model also sees. Without it the catalog
|
||||
// would silently under-report the shipped tool surface.
|
||||
const catalog = await collectToolCatalog()
|
||||
const subagent = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent')
|
||||
expect(subagent?.schemas.map(s => s.name)).toEqual(['subagent'])
|
||||
expect(subagent?.note).toMatch(/subagent_fork/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gen-tool-catalog assertManifestComplete', () => {
|
||||
it('passes when the manifest lists every on-disk tool package (the default)', () => {
|
||||
expect(() => { assertManifestComplete() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws, naming the omitted package, when a tool package is missing from the manifest', () => {
|
||||
// An empty manifest scanned against the real tree: every `tool-*` package
|
||||
// is unlisted, so the guard must fire and name them.
|
||||
expect(() => { assertManifestComplete([]) }).toThrow(/not in the boot manifest/)
|
||||
expect(() => { assertManifestComplete([]) }).toThrow(/tool-bash/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gen-tool-catalog render', () => {
|
||||
it('emits a package heading, a tool heading, and a json schema fence', () => {
|
||||
const catalog: ToolCatalog = [
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-demo',
|
||||
source: 'packages/demo/tool-demo/src/index.ts',
|
||||
schemas: [{ name: 'demo', description: 'A demo tool.', parameters: { type: 'object', properties: {} } }],
|
||||
},
|
||||
]
|
||||
const md = render(catalog)
|
||||
expect(md).toContain('## `@deepseek-ai/dsh-tool-demo`')
|
||||
expect(md).toContain('### `demo`')
|
||||
expect(md).toContain('A demo tool.')
|
||||
expect(md).toContain('```json')
|
||||
expect(md).toContain('Source: [`packages/demo/tool-demo/src/index.ts`]')
|
||||
})
|
||||
|
||||
it('renders the strict flag when a schema sets it', () => {
|
||||
const catalog: ToolCatalog = [
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-demo',
|
||||
source: 'packages/demo/tool-demo/src/index.ts',
|
||||
schemas: [{ name: 'demo', description: '', parameters: { type: 'object', properties: {} }, strict: true }],
|
||||
},
|
||||
]
|
||||
expect(render(catalog)).toContain('Strict: `true`')
|
||||
})
|
||||
})
|
||||
12
packages/fs/README.md
Normal file
12
packages/fs/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 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.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `fs/` | Provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` policy events | `ctx.fs` |
|
||||
| `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`) |
|
||||
|
||||
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.
|
||||
25
packages/fs/fs-local/README.md
Normal file
25
packages/fs/fs-local/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# @deepseek-ai/dsh-fs-local
|
||||
|
||||
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the six `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
|
||||
|
||||
```ts ignore-check
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
// ctx.fs is now the local backend; load @deepseek-ai/dsh-fs-policy for the
|
||||
// freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit.
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
|
||||
- **`stat`** — returns `FsInfo` (`version` = `mtimeMs:size`, `type` of `file`/`directory`/`other`, byte `size`) or `undefined` when the target is absent.
|
||||
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
|
||||
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
|
||||
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
|
||||
|
||||
## `cwd` is not a sandbox
|
||||
|
||||
`config.cwd` is a resolution default, not a containment boundary — absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall. See [the filesystem capability-seam RFC's Risks section](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md#risks).
|
||||
|
||||
The raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.
|
||||
36
packages/fs/fs-local/package.json
Normal file
36
packages/fs/fs-local/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-fs-local",
|
||||
"description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)",
|
||||
"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-fs": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
413
packages/fs/fs-local/src/fsio.ts
Normal file
413
packages/fs/fs-local/src/fsio.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* Cordis-free local-filesystem I/O for `@deepseek-ai/dsh-fs-local`. Kept
|
||||
* separate from the service class (mirroring `dsh-bash-local`'s `run.ts`) so
|
||||
* the raw stat/read/write/edit mechanics can be unit-tested without a Context.
|
||||
*
|
||||
* This is the PROVIDER layer: it hands back decoded whole-file text (validated
|
||||
* UTF-8, binary rejected) — never line windows or numbered lines, which are
|
||||
* model-facing read policy owned by `@deepseek-ai/dsh-fs-policy`. Large files
|
||||
* stream their text in chunks so a huge file never has to be held whole in
|
||||
* memory; the binary/NUL sample and cross-chunk UTF-8 decoding stay here.
|
||||
*
|
||||
* Writes are atomic: content goes to a temp file opened exclusively (`wx`,
|
||||
* `0o600`, so a pre-existing path can never be clobbered and write-in-progress
|
||||
* bytes stay owner-only) inside a randomly-named private staging directory
|
||||
* (`0o700`) next to the target, then `rename`d over the target. Edits are
|
||||
* read-modify-write over the same atomic primitive.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-local/fsio
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { chmod, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises'
|
||||
import type { Stats } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { TextDecoder } from 'node:util'
|
||||
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** Files at or above this size stream their text; smaller files read whole. */
|
||||
export const STREAM_MIN_SIZE = 10 * 1024 * 1024
|
||||
|
||||
const BINARY_SAMPLE_BYTES = 8192
|
||||
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/**
|
||||
* A path component that is expected to be a directory is a regular file (e.g.
|
||||
* resolving `afile/child.txt` when `afile` is a file). Like `ENOENT`, the target
|
||||
* cannot exist — so the resolution/probe paths treat it as "absent" rather than
|
||||
* letting a raw Node error escape without the structured `FsError` taxonomy.
|
||||
*/
|
||||
function isENOTDIR(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
/* v8 ignore start -- composes secondary cleanup-failure messages, which require a filesystem/kernel fault after the primary failure. */
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined, verb: string): void {
|
||||
if (signal?.aborted) throw new FsError(`${verb} aborted`, 'FS_ABORTED')
|
||||
}
|
||||
|
||||
/**
|
||||
* `readFile` with the supplied signal, translating a mid-read `AbortError` into
|
||||
* the seam's structured `FsError('FS_ABORTED')` (Node rejects an aborted
|
||||
* `readFile` with a bare `AbortError`, which would otherwise escape the seam's
|
||||
* error taxonomy — the streaming/write paths translate it the same way).
|
||||
*/
|
||||
async function readFileAbortable(absolutePath: string, verb: 'read' | 'edit', signal?: AbortSignal): Promise<Buffer> {
|
||||
try {
|
||||
return await readFile(absolutePath, signal ? { signal } : {})
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- a non-abort readFile rejection needs a permission/IO fault racing an open file. */
|
||||
if (!isAbortError(error)) throw error
|
||||
throw new FsError(`${verb} aborted`, 'FS_ABORTED')
|
||||
}
|
||||
}
|
||||
|
||||
/** Opaque version token from a stat: mtime (ns precision) + size. */
|
||||
function versionOf(info: Stats): FsVersion {
|
||||
return FsVersion(`${info.mtimeMs}:${info.size}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam: lets specs force the streaming read path (via a small
|
||||
* `streamMinSize`) and pin the temp-file name (to prove exclusive-open
|
||||
* behavior) without a 10 MB fixture or a name race.
|
||||
*/
|
||||
export interface FsIoInternals {
|
||||
/** Override {@link STREAM_MIN_SIZE} for read routing. */
|
||||
streamMinSize?: number
|
||||
/** Override the generated private staging-dir name (relative to the target dir). */
|
||||
tempDirName?: (writePath: string) => string
|
||||
/** Override the generated temp-file name (relative to the private staging dir). */
|
||||
tempName?: (writePath: string) => string
|
||||
/** Test hook after the temp file is written/synced but before final chmod+rename. */
|
||||
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** A resolved local path: the absolute path shown to callers and its realpath identity. */
|
||||
export interface LocalTarget {
|
||||
/** Absolute path (symlinks not resolved) — used for display. */
|
||||
displayPath: string
|
||||
/** Realpath identity — used as the stable target key and the I/O path. */
|
||||
targetKey: FsTargetKey
|
||||
}
|
||||
|
||||
/** Result of probing a path: null when it does not exist. */
|
||||
export interface PathInfo {
|
||||
version: FsVersion
|
||||
mode: number
|
||||
type: 'file' | 'directory' | 'other'
|
||||
size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path to its absolute display path and realpath identity. Relative
|
||||
* paths are based on `cwd`. When the file itself does not yet exist, the
|
||||
* `targetKey` realpaths the nearest EXISTING ancestor directory and re-appends
|
||||
* the still-missing suffix, so a not-yet-created file gets the same stable key
|
||||
* it will have after creation — even when an ancestor (e.g. `cwd`) is a symlink
|
||||
* and intermediate directories are created by the write. Two input paths
|
||||
* reaching the same file via symlinks share one key. Falls back to the absolute
|
||||
* path only when no ancestor (not even the filesystem root) can be resolved.
|
||||
*/
|
||||
export async function resolveLocalTarget(cwd: string, path: string): Promise<LocalTarget> {
|
||||
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
|
||||
const displayPath = resolve(cwd, path)
|
||||
try {
|
||||
// Prefer the file's own realpath (resolves a symlinked file to its target).
|
||||
return { displayPath, targetKey: FsTargetKey(await realpath(displayPath)) }
|
||||
} catch (error: unknown) {
|
||||
// A path component is a file, not a directory (e.g. "afile/child.txt" where
|
||||
// "afile" is a regular file): the target can neither exist nor be created,
|
||||
// so surface the structured taxonomy instead of a raw Node ENOTDIR.
|
||||
if (isENOTDIR(error)) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')
|
||||
/* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to ancestor resolution. */
|
||||
if (!isENOENT(error)) throw error
|
||||
}
|
||||
// File absent: realpath the nearest existing ancestor and re-append the
|
||||
// missing suffix (the file basename plus any not-yet-created intermediate
|
||||
// dirs), so the key is stable across creation of those dirs.
|
||||
const missing = [basename(displayPath)]
|
||||
let ancestor = dirname(displayPath)
|
||||
while (true) {
|
||||
try {
|
||||
const realAncestor = await realpath(ancestor)
|
||||
return { displayPath, targetKey: FsTargetKey(join(realAncestor, ...missing)) }
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- a non-ENOENT realpath failure needs a permission/IO fault. */
|
||||
if (!isENOENT(error)) throw error
|
||||
const parent = dirname(ancestor)
|
||||
/* v8 ignore next -- the filesystem root always realpaths, so the walk terminates before parent === ancestor. */
|
||||
if (parent === ancestor) return { displayPath, targetKey: FsTargetKey(displayPath) }
|
||||
missing.unshift(basename(ancestor))
|
||||
ancestor = parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe a path for its version, mode, type, and size. Null if absent. */
|
||||
export async function probe(absolutePath: string): Promise<PathInfo | null> {
|
||||
try {
|
||||
const info = await stat(absolutePath)
|
||||
const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
|
||||
return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size }
|
||||
} catch (error: unknown) {
|
||||
// ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean
|
||||
// the target is absent; any other stat failure is a real permission/IO fault.
|
||||
/* v8 ignore next -- a non-ENOENT/ENOTDIR stat failure needs a permission/IO fault; surface it. */
|
||||
if (!isENOENT(error) && !isENOTDIR(error)) throw error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reading ---
|
||||
|
||||
function notTextError(verb: 'read' | 'edit', displayPath: string): FsError {
|
||||
return new FsError(`cannot ${verb} "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT')
|
||||
}
|
||||
|
||||
function decodeUtf8(buffer: Uint8Array, verb: 'read' | 'edit', displayPath: string): string {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
|
||||
if (!(error instanceof TypeError)) throw error
|
||||
throw notTextError(verb, displayPath)
|
||||
}
|
||||
}
|
||||
|
||||
function decodeUtf8Stream(
|
||||
decoder: TextDecoder,
|
||||
chunk: Uint8Array | undefined,
|
||||
verb: 'read' | 'edit',
|
||||
displayPath: string,
|
||||
): string {
|
||||
try {
|
||||
return chunk ? decoder.decode(chunk, { stream: true }) : decoder.decode()
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
|
||||
if (!(error instanceof TypeError)) throw error
|
||||
throw notTextError(verb, displayPath)
|
||||
}
|
||||
}
|
||||
|
||||
async function statRegularFile(target: LocalTarget, verb: 'read', signal?: AbortSignal): Promise<Stats> {
|
||||
throwIfAborted(signal, verb)
|
||||
let info: Stats
|
||||
try {
|
||||
info = await stat(target.targetKey)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; only the not-found path is reachable in tests. */
|
||||
if (!isENOENT(error)) throw error
|
||||
throw new FsError(`cannot ${verb} "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
}
|
||||
if (!info.isFile()) throw new FsError(`cannot ${verb} "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a whole regular UTF-8 text file into a single decoded string. Rejects
|
||||
* non-regular files, invalid UTF-8, and NUL-byte binary samples.
|
||||
*/
|
||||
export async function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise<string> {
|
||||
await statRegularFile(target, 'read', signal)
|
||||
const raw = await readFileAbortable(target.targetKey, 'read', signal)
|
||||
throwIfAborted(signal, 'read')
|
||||
if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
|
||||
}
|
||||
return decodeUtf8(raw, 'read', target.displayPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a whole regular UTF-8 text file as decoded text chunks. Same text
|
||||
* semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,
|
||||
* cross-chunk UTF-8 decoding), but never holds the whole file in memory.
|
||||
*/
|
||||
export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal): AsyncIterable<string> {
|
||||
await statRegularFile(target, 'read', signal)
|
||||
const stream = createReadStream(target.targetKey, signal ? { signal } : {})
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true })
|
||||
let sampledBytes = 0
|
||||
|
||||
function scanBinarySample(chunk: Buffer): void {
|
||||
if (sampledBytes >= BINARY_SAMPLE_BYTES) return
|
||||
const sample = chunk.subarray(0, Math.min(chunk.length, BINARY_SAMPLE_BYTES - sampledBytes))
|
||||
if (sample.includes(0)) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
|
||||
}
|
||||
sampledBytes += sample.length
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const chunk of stream as AsyncIterable<Buffer>) {
|
||||
scanBinarySample(chunk)
|
||||
yield decodeUtf8Stream(decoder, chunk, 'read', target.displayPath)
|
||||
}
|
||||
yield decodeUtf8Stream(decoder, undefined, 'read', target.displayPath)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 4 -- mid-stream errors need an abort/IO fault racing the loop; pre-abort is caught by throwIfAborted. */
|
||||
if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// --- Writing ---
|
||||
|
||||
async function removeStagingDirOrThrow(stagingDir: string, originalError: unknown): Promise<never> {
|
||||
try {
|
||||
await rm(stagingDir, { recursive: true, force: true })
|
||||
} catch (cleanupError: unknown) {
|
||||
/* v8 ignore next 1 -- cleanup failure here needs a second filesystem fault after the primary write failure. */
|
||||
throw new FsError(`write failed (${errorMessage(originalError)}) and temp cleanup failed (${errorMessage(cleanupError)})`, 'FS_NOT_FOUND', { cause: originalError })
|
||||
}
|
||||
throw originalError
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically write `content` to `absolutePath`: create parent dirs, write to a
|
||||
* randomly-named temp file opened exclusively (`wx`, `0o600`) inside a private
|
||||
* (`0o700`) staging directory, fsync, optionally chmod to the final mode while
|
||||
* still private, then rename over the target. `mode` (when given) preserves an
|
||||
* existing file's permissions across the replace.
|
||||
*/
|
||||
export async function writeFileAtomic(
|
||||
absolutePath: string,
|
||||
content: string,
|
||||
mode: number | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
internals: FsIoInternals = {},
|
||||
): Promise<void> {
|
||||
throwIfAborted(signal, 'write')
|
||||
const directory = dirname(absolutePath)
|
||||
await mkdir(directory, { recursive: true })
|
||||
|
||||
throwIfAborted(signal, 'write')
|
||||
const stagingDirName = internals.tempDirName?.(absolutePath) ?? `.${basename(absolutePath)}.${process.pid}.${randomUUID()}.tmpdir`
|
||||
const stagingDir = join(directory, stagingDirName)
|
||||
const tempName = internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp`
|
||||
const tempPath = join(stagingDir, tempName)
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined
|
||||
let stagingCreated = false
|
||||
try {
|
||||
await mkdir(stagingDir, { mode: 0o700 })
|
||||
stagingCreated = true
|
||||
await chmod(stagingDir, 0o700)
|
||||
|
||||
handle = await open(tempPath, 'wx', 0o600)
|
||||
await handle.chmod(0o600)
|
||||
await handle.writeFile(content, { encoding: 'utf8', ...signal ? { signal } : {} })
|
||||
await handle.sync()
|
||||
await internals.inspectTemp?.({ stagingDir, tempPath })
|
||||
if (mode !== undefined) await handle.chmod(mode)
|
||||
await handle.close()
|
||||
handle = undefined
|
||||
|
||||
throwIfAborted(signal, 'write')
|
||||
await rename(tempPath, absolutePath)
|
||||
await rm(stagingDir, { recursive: true, force: true })
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */
|
||||
let failure: unknown = isAbortError(error) ? new FsError('write aborted', 'FS_ABORTED') : error
|
||||
/* v8 ignore next 8 -- reached only if writeFile/sync throws with the handle open (IO fault); close-failure is a double fault. */
|
||||
if (handle) {
|
||||
try {
|
||||
await handle.close()
|
||||
} catch (closeError: unknown) {
|
||||
failure = new FsError(`write failed (${errorMessage(failure)}) and temp close failed (${errorMessage(closeError)})`, 'FS_NOT_FOUND', { cause: failure })
|
||||
}
|
||||
}
|
||||
if (!stagingCreated) throw failure
|
||||
return removeStagingDirOrThrow(stagingDir, failure)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Editing ---
|
||||
|
||||
/** Line ending style detected before LF normalization. */
|
||||
export type LineEndings = 'LF' | 'CRLF'
|
||||
|
||||
function normalizeLineEndings(content: string): string {
|
||||
return content.replaceAll('\r\n', '\n')
|
||||
}
|
||||
|
||||
function detectLineEndings(raw: string): LineEndings {
|
||||
const sample = raw.slice(0, 4096)
|
||||
const crlfCount = sample.split('\r\n').length - 1
|
||||
const lfCount = sample.split('\n').length - 1 - crlfCount
|
||||
return crlfCount > lfCount ? 'CRLF' : 'LF'
|
||||
}
|
||||
|
||||
function restoreLineEndings(content: string, lineEndings: LineEndings): string {
|
||||
return lineEndings === 'LF' ? content : normalizeLineEndings(content).split('\n').join('\r\n')
|
||||
}
|
||||
|
||||
function countOccurrences(content: string, needle: string): number {
|
||||
let count = 0
|
||||
let index = 0
|
||||
while (true) {
|
||||
const found = content.indexOf(needle, index)
|
||||
if (found === -1) return count
|
||||
count += 1
|
||||
index = found + needle.length
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and decode a file for editing: rejects binaries, returns LF-normalized
|
||||
* content plus the original line-ending style for write-back.
|
||||
*/
|
||||
export async function readForEdit(
|
||||
absolutePath: string,
|
||||
displayPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ content: string; lineEndings: LineEndings }> {
|
||||
throwIfAborted(signal, 'edit')
|
||||
const buffer = await readFileAbortable(absolutePath, 'edit', signal)
|
||||
throwIfAborted(signal, 'edit')
|
||||
if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, 'FS_NOT_TEXT')
|
||||
const raw = decodeUtf8(buffer, 'edit', displayPath)
|
||||
return { content: normalizeLineEndings(raw), lineEndings: detectLineEndings(raw) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a literal replacement to LF-normalized content. Throws
|
||||
* `FS_EDIT_NOT_FOUND` on empty `oldString` or zero matches and
|
||||
* `FS_AMBIGUOUS_EDIT` on multiple matches when `replaceAll` is false. Returns
|
||||
* the edited content (still LF-normalized) and the replacement count.
|
||||
*/
|
||||
export function applyLiteralEdit(
|
||||
content: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
replaceAll: boolean,
|
||||
displayPath: string,
|
||||
): { content: string; replacements: number } {
|
||||
const oldNorm = normalizeLineEndings(oldString)
|
||||
if (oldNorm.length === 0) {
|
||||
throw new FsError('old_string must be a non-empty string', 'FS_EDIT_NOT_FOUND')
|
||||
}
|
||||
const newNorm = normalizeLineEndings(newString)
|
||||
const replacements = countOccurrences(content, oldNorm)
|
||||
if (replacements === 0) {
|
||||
throw new FsError(`old_string was not found in "${displayPath}"`, 'FS_EDIT_NOT_FOUND')
|
||||
}
|
||||
if (!replaceAll && replacements > 1) {
|
||||
throw new FsError(`old_string matched ${replacements} times in "${displayPath}"; provide a more specific old_string or set replace_all to true`, 'FS_AMBIGUOUS_EDIT')
|
||||
}
|
||||
return { content: content.split(oldNorm).join(newNorm), replacements }
|
||||
}
|
||||
|
||||
export { restoreLineEndings }
|
||||
198
packages/fs/fs-local/src/index.ts
Normal file
198
packages/fs/fs-local/src/index.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Local-filesystem implementation of the `ctx.fs` provider seam.
|
||||
* {@link LocalFileSystem} subclasses {@link FileSystem} and backs the six
|
||||
* text-storage primitives with the host filesystem via
|
||||
* {@link module:@deepseek-ai/dsh-fs-local/fsio}. Path resolution uses
|
||||
* `realpath`, so the stable `targetKey` is the real file identity (two input
|
||||
* paths reaching the same file through symlinks share one key, and writes land
|
||||
* on the link target — preserving the link).
|
||||
*
|
||||
* Future sandboxed/remote/virtual backends are sibling packages implementing
|
||||
* the same interface; loading this one populates `ctx.fs`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-local
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
import {
|
||||
applyLiteralEdit,
|
||||
probe,
|
||||
readForEdit,
|
||||
readWholeText,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
streamWholeText,
|
||||
writeFileAtomic,
|
||||
} from './fsio.ts'
|
||||
import type { FsIoInternals } from './fsio.ts'
|
||||
|
||||
export {
|
||||
STREAM_MIN_SIZE,
|
||||
applyLiteralEdit,
|
||||
probe,
|
||||
readForEdit,
|
||||
readWholeText,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
streamWholeText,
|
||||
writeFileAtomic,
|
||||
} from './fsio.ts'
|
||||
export type { FsIoInternals, LineEndings, LocalTarget, PathInfo } from './fsio.ts'
|
||||
|
||||
/** Configuration for the local filesystem backend. */
|
||||
export interface Config {
|
||||
/** Base directory for relative paths. Defaults to `process.cwd()`. */
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/**
|
||||
* The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}
|
||||
* (a resolution default, NOT a containment boundary — see the filesystem
|
||||
* capability-seam RFC); enforce
|
||||
* containment with a stricter backend or a `tools/execute` permission plugin.
|
||||
*/
|
||||
export class LocalFileSystem extends FileSystem {
|
||||
static Config: z<Config> = z.object({
|
||||
cwd: z.string().default(process.cwd()),
|
||||
})
|
||||
|
||||
readonly config: ResolvedConfig
|
||||
/** Test seam forwarded to fsio (force streaming path, pin temp names). */
|
||||
internals: FsIoInternals = {}
|
||||
/** Per-targetKey tail promise: serializes mutating ops so the read→guard→write
|
||||
* window can't interleave, making concurrent writes/edits deterministically
|
||||
* ordered (one wins, the rest see the new version and reject as stale). */
|
||||
private locks = new Map<string, Promise<unknown>>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
this.config = config as ResolvedConfig
|
||||
}
|
||||
|
||||
/** Run `op` with exclusive access to `targetKey` (FIFO per key). */
|
||||
private async withLock<T>(targetKey: string, op: () => Promise<T>): Promise<T> {
|
||||
const prior = this.locks.get(targetKey) ?? Promise.resolve()
|
||||
const run = prior.then(op, op)
|
||||
// Keep the chain alive but swallow this op's result/throw for the *next* waiter.
|
||||
const tail = run.then(() => undefined, () => undefined)
|
||||
this.locks.set(targetKey, tail)
|
||||
try {
|
||||
return await run
|
||||
} finally {
|
||||
if (this.locks.get(targetKey) === tail) {
|
||||
this.locks.delete(targetKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override async resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget> {
|
||||
const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)
|
||||
return { inputPath: path, targetKey: local.targetKey, displayPath: local.displayPath }
|
||||
}
|
||||
|
||||
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
|
||||
if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
|
||||
const info = await probe(target.targetKey)
|
||||
if (!info) return undefined
|
||||
return { version: info.version, type: info.type, size: info.size }
|
||||
}
|
||||
|
||||
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
|
||||
return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
|
||||
}
|
||||
|
||||
override streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
|
||||
return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))
|
||||
}
|
||||
|
||||
override async writeText(
|
||||
target: FsTarget,
|
||||
content: string,
|
||||
expected?: FsWriteIntent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FsWriteOutcome> {
|
||||
return this.withLock(target.targetKey, async () => {
|
||||
const existing = await probe(target.targetKey)
|
||||
if (existing && existing.type !== 'file') {
|
||||
throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
}
|
||||
|
||||
if (expected?.kind === 'replaceIfVersion') {
|
||||
// Stale guard: the file must still exist at the version the owner observed.
|
||||
if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION')
|
||||
if (existing.version !== expected.version) {
|
||||
throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
||||
}
|
||||
} else if (expected?.kind === 'createIfAbsent' && existing) {
|
||||
// createIfAbsent onto an existing file: a blind overwrite — require a read first.
|
||||
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
|
||||
}
|
||||
// expected === undefined: unconditional create-or-overwrite (the bare
|
||||
// provider) — no version guard, no read-first requirement. Still atomic
|
||||
// (the per-target lock is unconditional), so the write is never torn.
|
||||
|
||||
await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals)
|
||||
const after = await probe(target.targetKey)
|
||||
return {
|
||||
operation: existing ? 'update' : 'create',
|
||||
version: this.versionAfterWrite(after, target),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override async editText(
|
||||
target: FsTarget,
|
||||
edit: FsEditRequest,
|
||||
expected?: { version: FsVersion },
|
||||
signal?: AbortSignal,
|
||||
): Promise<FsEditOutcome> {
|
||||
return this.withLock(target.targetKey, async () => {
|
||||
const existing = await probe(target.targetKey)
|
||||
// Stale guard BEFORE literal matching: an edit based on an old read reports
|
||||
// FS_STALE_VERSION, not FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT against newer content.
|
||||
// A missing target reports FS_STALE_VERSION on BOTH paths (guarded and
|
||||
// unconditional) — one "cannot edit this target now" code.
|
||||
if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
||||
if (existing.type !== 'file') throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
// expected === undefined: unconditional edit of the current content — no
|
||||
// version guard. Still inside the per-target lock, so the read→match→write
|
||||
// window is serialized and atomic.
|
||||
if (expected && existing.version !== expected.version) {
|
||||
throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
||||
}
|
||||
|
||||
const original = await readForEdit(target.targetKey, target.displayPath, signal)
|
||||
const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath)
|
||||
const content = restoreLineEndings(edited.content, original.lineEndings)
|
||||
await writeFileAtomic(target.targetKey, content, existing.mode, signal, this.internals)
|
||||
|
||||
const after = await probe(target.targetKey)
|
||||
return {
|
||||
replacements: edited.replacements,
|
||||
replaceAll: edit.replaceAll,
|
||||
version: this.versionAfterWrite(after, target),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* v8 ignore next 5 -- the post-write probe finding the file absent requires a
|
||||
* concurrent unlink between rename and stat; fall back to a sentinel version. */
|
||||
private versionAfterWrite(after: { version: FsVersion } | null, target: FsTarget): FsVersion {
|
||||
if (after) return after.version
|
||||
return FsVersion(`missing:${target.targetKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalFileSystem
|
||||
405
packages/fs/fs-local/tests/filesystem.spec.ts
Normal file
405
packages/fs/fs-local/tests/filesystem.spec.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* Tests for the local backend through the `ctx.fs` provider seam: stat, whole-
|
||||
* file/streamed text reads, atomic guarded writes (createIfAbsent /
|
||||
* replaceIfVersion), version-guarded literal edits, concurrency races, symlink
|
||||
* identity, and HMR/disposal. Read WINDOWING is policy and lives in
|
||||
* `dsh-fs-policy`, so it is not exercised here.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, readFile, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import { FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
let dir: string
|
||||
let ctx: Context
|
||||
let fs: LocalFileSystem
|
||||
let fiber: Awaited<ReturnType<Context['plugin']>>
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-fs-'))
|
||||
ctx = new Context()
|
||||
fiber = await ctx.plugin(LocalFileSystem, { cwd: dir })
|
||||
fs = ctx.fs as LocalFileSystem
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fiber.dispose()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function lockCount(localFs: LocalFileSystem): number {
|
||||
return (localFs as unknown as { locks: Map<string, Promise<unknown>> }).locks.size
|
||||
}
|
||||
|
||||
/** The version the backend currently reports for a resolved target. */
|
||||
async function versionOf(target: FsTarget): Promise<FsVersion> {
|
||||
const info = await fs.stat(target)
|
||||
if (!info) throw new Error('expected target to exist')
|
||||
return info.version
|
||||
}
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers LocalFileSystem as ctx.fs with a default cwd', async () => {
|
||||
const bare = new Context()
|
||||
const bareFiber = await bare.plugin(LocalFileSystem)
|
||||
expect((bare.fs as LocalFileSystem).config.cwd).toBe(process.cwd())
|
||||
await bareFiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve', () => {
|
||||
it('resolves a relative path against opts.cwd, not config.cwd', async () => {
|
||||
// config.cwd is `dir`; a call supplying a DIFFERENT cwd bases the relative
|
||||
// path there (the per-session-workspace seam — mirrors tool-bash workdir).
|
||||
const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-'))
|
||||
try {
|
||||
await writeFile(join(other, 'x.txt'), 'in other')
|
||||
const viaOther = await fs.resolve('x.txt', { cwd: other })
|
||||
expect(await fs.readText(viaOther)).toBe('in other')
|
||||
// Same relative path with no opts falls back to config.cwd (= dir), where
|
||||
// x.txt does not exist.
|
||||
await expect(fs.readText(await fs.resolve('x.txt'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
} finally {
|
||||
await rm(other, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores opts.cwd for an ABSOLUTE path', async () => {
|
||||
await writeFile(join(dir, 'abs.txt'), 'absolute')
|
||||
const target = await fs.resolve(join(dir, 'abs.txt'), { cwd: '/nonexistent-base' })
|
||||
expect(await fs.readText(target)).toBe('absolute')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stat', () => {
|
||||
it('returns file metadata, directory type, and undefined for absent', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello')
|
||||
const fileInfo = await fs.stat(await fs.resolve('a.txt'))
|
||||
expect(fileInfo?.type).toBe('file')
|
||||
expect(fileInfo?.size).toBe(5)
|
||||
expect(typeof fileInfo?.version).toBe('string')
|
||||
|
||||
expect((await fs.stat(await fs.resolve('.')))?.type).toBe('directory')
|
||||
expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
await expect(fs.stat(await fs.resolve('a.txt'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('readText / streamText', () => {
|
||||
it('reads whole-file text', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
|
||||
expect(await fs.readText(await fs.resolve('a.txt'))).toBe('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
it('streams the same text', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
|
||||
const target = await fs.resolve('a.txt')
|
||||
let streamed = ''
|
||||
for await (const chunk of await fs.streamText(target)) streamed += chunk
|
||||
expect(streamed).toBe('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
it('rejects a missing file, a directory, binary, and invalid UTF-8', async () => {
|
||||
await expect(fs.readText(await fs.resolve('nope'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(fs.readText(await fs.resolve('.'))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
|
||||
await expect(fs.readText(await fs.resolve('bin'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
await expect(fs.readText(await fs.resolve('bad'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeText', () => {
|
||||
it('createIfAbsent creates a new file', async () => {
|
||||
const target = await fs.resolve('new.txt')
|
||||
const outcome = await fs.writeText(target, 'fresh', { kind: 'createIfAbsent' })
|
||||
expect(outcome.operation).toBe('create')
|
||||
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
|
||||
})
|
||||
|
||||
it('createIfAbsent rejects an existing file as FS_NOT_OBSERVED', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old')
|
||||
const target = await fs.resolve('a.txt')
|
||||
await expect(fs.writeText(target, 'new', { kind: 'createIfAbsent' }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('old')
|
||||
})
|
||||
|
||||
it('replaceIfVersion replaces when the version matches', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.writeText(target, 'new', { kind: 'replaceIfVersion', version: await versionOf(target) })
|
||||
expect(outcome.operation).toBe('update')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('new')
|
||||
})
|
||||
|
||||
it('replaceIfVersion rejects a stale version', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'v1')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const stale = await versionOf(target)
|
||||
await writeFile(join(dir, 'a.txt'), 'changed-externally')
|
||||
await expect(fs.writeText(target, 'v2', { kind: 'replaceIfVersion', version: stale }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('replaceIfVersion rejects a deleted target as stale, without recreating it', async () => {
|
||||
const path = join(dir, 'a.txt')
|
||||
await writeFile(path, 'v1')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
await unlink(path)
|
||||
await expect(fs.writeText(target, 'v2', { kind: 'replaceIfVersion', version }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('rejects writing onto a directory', async () => {
|
||||
const target = await fs.resolve('.')
|
||||
await expect(fs.writeText(target, 'x', { kind: 'createIfAbsent' }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('unconditionally creates a new file with no expectation (bare provider)', async () => {
|
||||
const target = await fs.resolve('new.txt')
|
||||
const outcome = await fs.writeText(target, 'fresh')
|
||||
expect(outcome.operation).toBe('create')
|
||||
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
|
||||
})
|
||||
|
||||
it('unconditionally OVERWRITES an existing file with no expectation (bare provider)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.writeText(target, 'clobbered')
|
||||
expect(outcome.operation).toBe('update')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered')
|
||||
})
|
||||
|
||||
it('rejects writing onto a directory even with no expectation', async () => {
|
||||
const target = await fs.resolve('.')
|
||||
await expect(fs.writeText(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('releases per-target mutation locks after success and failure', async () => {
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.writeText(target, 'created', { kind: 'createIfAbsent' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
await expect(fs.writeText(target, 'again', { kind: 'createIfAbsent' }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
|
||||
it('replaceIfVersion returns the post-write version (matches a fresh stat)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'v1')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const before = await versionOf(target)
|
||||
// Change the byte length so the mtimeMs:size token provably differs (a
|
||||
// same-size same-tick rewrite can collide — the documented version-token
|
||||
// limitation; not what this test is about).
|
||||
const outcome = await fs.writeText(target, 'a much longer replacement body', { kind: 'replaceIfVersion', version: before })
|
||||
expect(outcome.version).not.toBe(before)
|
||||
expect(outcome.version).toBe(await versionOf(target))
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal without creating the file', async () => {
|
||||
const target = await fs.resolve('aborted.txt')
|
||||
await expect(fs.writeText(target, 'x', undefined, AbortSignal.abort()))
|
||||
.rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
await expect(stat(join(dir, 'aborted.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
|
||||
it('two concurrent guarded writes: one updates, the other is rejected as stale', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'base')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
const results = await Promise.allSettled([
|
||||
fs.writeText(target, 'one', { kind: 'replaceIfVersion', version }),
|
||||
fs.writeText(target, 'two', { kind: 'replaceIfVersion', version }),
|
||||
])
|
||||
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
|
||||
const rejected = results.filter(r => r.status === 'rejected')
|
||||
expect(rejected).toHaveLength(1)
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('editText', () => {
|
||||
it('applies a literal edit at the matching version', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: await versionOf(target) })
|
||||
expect(outcome.replacements).toBe(1)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
it('checks the stale version BEFORE literal matching', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const stale = await versionOf(target)
|
||||
// Change the file so 'world' is gone — a stale edit must report STALE, not NOT_FOUND.
|
||||
await writeFile(join(dir, 'a.txt'), 'goodbye')
|
||||
await expect(fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: stale }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('unconditionally edits the current content with no expectation (bare provider)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const target = await fs.resolve('a.txt')
|
||||
// No version guard: any current content is edited, regardless of version.
|
||||
const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false })
|
||||
expect(outcome.replacements).toBe(1)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
it('reports a missing target as FS_STALE_VERSION even with no expectation (bare provider)', async () => {
|
||||
const target = await fs.resolve('missing.txt')
|
||||
await expect(fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: false }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('still reports literal-match codes with no expectation (FS_EDIT_NOT_FOUND, unrelated to freshness)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const target = await fs.resolve('a.txt')
|
||||
await expect(fs.editText(target, { oldString: 'absent', newString: 'x', replaceAll: false }))
|
||||
.rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('rejects a deleted target as stale (before matching)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
await unlink(join(dir, 'a.txt'))
|
||||
await expect(fs.editText(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('rejects a non-regular target', async () => {
|
||||
const target = await fs.resolve('.')
|
||||
await expect(fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: false }, { version: FsVersion('v') }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('rejects zero matches and ambiguous matches at the right version', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a a a')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
await expect(fs.editText(target, { oldString: 'z', newString: 'X', replaceAll: false }, { version }))
|
||||
.rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
|
||||
await expect(fs.editText(target, { oldString: 'a', newString: 'X', replaceAll: false }, { version }))
|
||||
.rejects.toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
|
||||
})
|
||||
|
||||
it('replaces all matches with replaceAll', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a a a')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: true }, { version: await versionOf(target) })
|
||||
expect(outcome.replacements).toBe(3)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
|
||||
})
|
||||
|
||||
it('rejects invalid UTF-8 without rewriting the file', async () => {
|
||||
const path = join(dir, 'bad.txt')
|
||||
const bytes = Buffer.from([0x68, 0xff, 0x69])
|
||||
await writeFile(path, bytes)
|
||||
const target = await fs.resolve('bad.txt')
|
||||
const version = await versionOf(target)
|
||||
await expect(fs.editText(target, { oldString: 'h', newString: 'H', replaceAll: false }, { version }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
expect(await readFile(path)).toEqual(bytes)
|
||||
})
|
||||
|
||||
it('two concurrent edits: one wins, the other is rejected as stale', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'base')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
const results = await Promise.allSettled([
|
||||
fs.editText(target, { oldString: 'base', newString: 'one', replaceAll: false }, { version }),
|
||||
fs.editText(target, { oldString: 'base', newString: 'two', replaceAll: false }, { version }),
|
||||
])
|
||||
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
|
||||
const rejected = results.filter(r => r.status === 'rejected')
|
||||
expect(rejected).toHaveLength(1)
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal without rewriting the file', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'keep')
|
||||
const target = await fs.resolve('a.txt')
|
||||
await expect(fs.editText(target, { oldString: 'keep', newString: 'x', replaceAll: false }, undefined, AbortSignal.abort()))
|
||||
.rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('keep')
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
|
||||
it('a successful edit refreshes the version so an immediate follow-up edit proceeds', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one two')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const first = await fs.editText(target, { oldString: 'one', newString: 'ONE', replaceAll: false }, { version: await versionOf(target) })
|
||||
// The version the first edit returned is a valid guard for a second edit —
|
||||
// no intervening re-stat needed.
|
||||
const second = await fs.editText(target, { oldString: 'two', newString: 'TWO', replaceAll: false }, { version: first.version })
|
||||
expect(second.replacements).toBe(1)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('ONE TWO')
|
||||
})
|
||||
|
||||
it('concurrent write vs edit at the same version: one wins, the other is stale', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'base')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
const results = await Promise.allSettled([
|
||||
fs.writeText(target, 'written', { kind: 'replaceIfVersion', version }),
|
||||
fs.editText(target, { oldString: 'base', newString: 'edited', replaceAll: false }, { version }),
|
||||
])
|
||||
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
|
||||
const rejected = results.filter(r => r.status === 'rejected')
|
||||
expect(rejected).toHaveLength(1)
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('symlink targetKey identity', () => {
|
||||
it('two paths to the same file via a symlink share one version and write the real target', async () => {
|
||||
await writeFile(join(dir, 'real.txt'), 'hello')
|
||||
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
|
||||
const viaReal = await fs.resolve('real.txt')
|
||||
const viaLink = await fs.resolve('link.txt')
|
||||
expect(viaLink.targetKey).toBe(viaReal.targetKey)
|
||||
|
||||
const version = await versionOf(viaReal)
|
||||
await fs.editText(viaLink, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version })
|
||||
expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('bye') // link preserved
|
||||
})
|
||||
|
||||
it('a stale change is detected across both paths', async () => {
|
||||
await writeFile(join(dir, 'real.txt'), 'hello')
|
||||
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
|
||||
const viaReal = await fs.resolve('real.txt')
|
||||
const stale = await versionOf(viaReal)
|
||||
await writeFile(join(dir, 'real.txt'), 'changed')
|
||||
const viaLink = await fs.resolve('link.txt')
|
||||
await expect(fs.editText(viaLink, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version: stale }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('HMR / disposal', () => {
|
||||
it('disposing the fiber withdraws ctx.fs', async () => {
|
||||
const local = new Context()
|
||||
const localFiber = await local.plugin(LocalFileSystem, { cwd: dir })
|
||||
expect(local.fs).toBeDefined()
|
||||
await localFiber.dispose()
|
||||
expect(local.fs).toBeUndefined()
|
||||
})
|
||||
})
|
||||
363
packages/fs/fs-local/tests/fsio.spec.ts
Normal file
363
packages/fs/fs-local/tests/fsio.spec.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Cordis-free tests for the raw local-filesystem I/O: path resolution, probe,
|
||||
* whole-file/streamed text reads, binary/UTF-8 rejection, atomic-write temp
|
||||
* safety, literal edit matching, and line-ending handling. Line WINDOWING is
|
||||
* policy and lives in `dsh-fs-policy`, so it is not tested here.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, readFile, rm, stat, symlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer } from 'node:net'
|
||||
import {
|
||||
applyLiteralEdit,
|
||||
probe,
|
||||
readForEdit,
|
||||
readWholeText,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
streamWholeText,
|
||||
writeFileAtomic,
|
||||
} from '@deepseek-ai/dsh-fs-local'
|
||||
import type { LocalTarget } from '@deepseek-ai/dsh-fs-local'
|
||||
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
let dir: string
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-fsio-'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: FsTargetKey(path) })
|
||||
|
||||
async function collect(chunks: AsyncIterable<string>): Promise<string> {
|
||||
let out = ''
|
||||
for await (const chunk of chunks) out += chunk
|
||||
return out
|
||||
}
|
||||
|
||||
describe('resolveLocalTarget', () => {
|
||||
it('resolves a relative path from cwd and realpaths it', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'hi')
|
||||
const target = await resolveLocalTarget(dir, 'a.txt')
|
||||
expect(target.displayPath).toBe(file)
|
||||
expect(target.targetKey).toBe(await realpath(file))
|
||||
})
|
||||
|
||||
it('uses the realpathed parent + basename when the file does not exist (stable across create)', async () => {
|
||||
const target = await resolveLocalTarget(dir, 'missing.txt')
|
||||
expect(target.targetKey).toBe(join(await realpath(dir), 'missing.txt'))
|
||||
})
|
||||
|
||||
it('two paths to the same file via a symlink share one targetKey', async () => {
|
||||
const real = join(dir, 'real.txt')
|
||||
await writeFile(real, 'hi')
|
||||
const link = join(dir, 'link.txt')
|
||||
await symlink(real, link)
|
||||
const viaReal = await resolveLocalTarget(dir, 'real.txt')
|
||||
const viaLink = await resolveLocalTarget(dir, 'link.txt')
|
||||
expect(viaLink.targetKey).toBe(viaReal.targetKey)
|
||||
expect(viaLink.displayPath).toBe(link)
|
||||
})
|
||||
|
||||
it('realpaths the nearest existing ancestor when intermediate dirs are missing', async () => {
|
||||
const target = await resolveLocalTarget(dir, 'no-such-dir/child.txt')
|
||||
expect(target.targetKey).toBe(join(await realpath(dir), 'no-such-dir', 'child.txt'))
|
||||
})
|
||||
|
||||
it('keeps the key stable across create when an ancestor is a symlink', async () => {
|
||||
// A symlinked workspace root with a not-yet-created subdirectory: the
|
||||
// pre-create key (via the symlink, missing parent) must equal the
|
||||
// post-create key (file exists, realpathed) so observed-state survives.
|
||||
const realRoot = join(dir, 'real-root')
|
||||
await mkdir(realRoot)
|
||||
const linkRoot = join(dir, 'link-root')
|
||||
await symlink(realRoot, linkRoot)
|
||||
|
||||
const before = await resolveLocalTarget(linkRoot, 'sub/file.txt')
|
||||
await mkdir(join(realRoot, 'sub'), { recursive: true })
|
||||
await writeFile(join(realRoot, 'sub', 'file.txt'), 'hi') // create through the real path
|
||||
const after = await resolveLocalTarget(linkRoot, 'sub/file.txt')
|
||||
expect(before.targetKey).toBe(after.targetKey)
|
||||
})
|
||||
|
||||
it('rejects a blank path', async () => {
|
||||
await expect(resolveLocalTarget(dir, ' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('rejects a path whose ancestor is a file with a structured FsError (ENOTDIR)', async () => {
|
||||
// "afile" is a regular file, so "afile/child.txt" hits ENOTDIR on realpath;
|
||||
// the raw Node error must be translated into the FsError taxonomy so the tool
|
||||
// result keeps its { name, code } metadata.
|
||||
await writeFile(join(dir, 'afile'), 'i am a file')
|
||||
const err = await resolveLocalTarget(dir, 'afile/child.txt').then(() => undefined, (e: unknown) => e)
|
||||
expect(err).toBeInstanceOf(FsError)
|
||||
expect(err).toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('probe', () => {
|
||||
it('returns null for a missing path and metadata for a file', async () => {
|
||||
expect(await probe(join(dir, 'nope'))).toBeNull()
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'hi')
|
||||
const info = await probe(file)
|
||||
expect(info?.type).toBe('file')
|
||||
expect(info?.size).toBe(2)
|
||||
expect(typeof info?.version).toBe('string')
|
||||
})
|
||||
|
||||
it('reports a directory and a non-regular type', async () => {
|
||||
const sub = join(dir, 'sub')
|
||||
await mkdir(sub)
|
||||
expect((await probe(sub))?.type).toBe('directory')
|
||||
})
|
||||
|
||||
it('reports a socket/special file as type "other"', async () => {
|
||||
const sockPath = join(dir, 'sock')
|
||||
const server = createServer()
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(sockPath, () => { resolve() })
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// A restricted sandbox may forbid unix-domain sockets; that is an
|
||||
// environment limit, not a filesystem regression — skip rather than fail.
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'EPERM' || code === 'EACCES' || code === 'ENOTSUP') return
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
expect((await probe(sockPath))?.type).toBe('other')
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => { server.close(() => { resolve() }) })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null when an ancestor path segment is a file (ENOTDIR), not a raw throw', async () => {
|
||||
await writeFile(join(dir, 'afile'), 'i am a file')
|
||||
expect(await probe(join(dir, 'afile', 'child.txt'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readWholeText', () => {
|
||||
it('reads a small file', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo\nthree')
|
||||
expect(await readWholeText(localTarget(file))).toBe('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
it('rejects a missing file and a directory', async () => {
|
||||
await expect(readWholeText(localTarget(join(dir, 'nope')))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(readWholeText(localTarget(dir))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('rejects binary and invalid UTF-8', async () => {
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
|
||||
await expect(readWholeText(localTarget(join(dir, 'bin')))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
await expect(readWholeText(localTarget(join(dir, 'bad')))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one')
|
||||
await expect(readWholeText(localTarget(file), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
expect(await readWholeText(localTarget(file), new AbortController().signal)).toBe('one\ntwo')
|
||||
})
|
||||
|
||||
it('translates a mid-read AbortError into FS_ABORTED', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const ac = new AbortController()
|
||||
// Abort after the synchronous entry check but before readFile runs (the
|
||||
// stat await yields control back here), so readFile rejects AbortError.
|
||||
const pending = readWholeText(localTarget(file), ac.signal)
|
||||
ac.abort()
|
||||
await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('streamWholeText', () => {
|
||||
it('streams the whole file as decoded text', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo\nthree')
|
||||
expect(await collect(streamWholeText(localTarget(file)))).toBe('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
it('streams a large multi-chunk file correctly', async () => {
|
||||
const file = join(dir, 'big.txt')
|
||||
const content = Array.from({ length: 50 }, (_, i) => `line ${i}: ${'x'.repeat(3000)}`).join('\n')
|
||||
await writeFile(file, content)
|
||||
expect(await collect(streamWholeText(localTarget(file)))).toBe(content)
|
||||
})
|
||||
|
||||
it('rejects a missing file, directory, binary, and invalid UTF-8', async () => {
|
||||
await expect(collect(streamWholeText(localTarget(join(dir, 'nope'))))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(collect(streamWholeText(localTarget(dir)))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
|
||||
await expect(collect(streamWholeText(localTarget(join(dir, 'bin'))))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
await expect(collect(streamWholeText(localTarget(join(dir, 'bad'))))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one')
|
||||
await expect(collect(streamWholeText(localTarget(file), AbortSignal.abort()))).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the stream', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
expect(await collect(streamWholeText(localTarget(file), new AbortController().signal))).toBe('one\ntwo')
|
||||
})
|
||||
|
||||
it('translates a mid-stream abort into FS_ABORTED', async () => {
|
||||
// A multi-chunk file so the stream yields more than once; abort after the
|
||||
// first chunk and assert the structured code, not a raw AbortError.
|
||||
const file = join(dir, 'big.txt')
|
||||
await writeFile(file, 'x'.repeat(256 * 1024))
|
||||
const ac = new AbortController()
|
||||
const run = async (): Promise<void> => {
|
||||
let seen = 0
|
||||
for await (const _chunk of streamWholeText(localTarget(file), ac.signal)) {
|
||||
seen += 1
|
||||
if (seen === 1) ac.abort()
|
||||
}
|
||||
}
|
||||
await expect(run()).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeFileAtomic — temp-file safety', () => {
|
||||
it('writes through a private staging dir and owner-only temp file', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
let inspected = false
|
||||
await writeFileAtomic(file, 'hello', 0o640, undefined, {
|
||||
inspectTemp: async ({ stagingDir, tempPath }) => {
|
||||
inspected = true
|
||||
expect((await stat(stagingDir)).mode & 0o777).toBe(0o700)
|
||||
expect((await stat(tempPath)).mode & 0o777).toBe(0o600)
|
||||
},
|
||||
})
|
||||
expect(inspected).toBe(true)
|
||||
expect(await readFile(file, 'utf8')).toBe('hello')
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o640)
|
||||
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
|
||||
it('creates new files owner-only by default', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFileAtomic(file, 'hello', undefined, undefined)
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('opens staging paths exclusively — a pre-existing path is never clobbered', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
const tempDirName = '.fixed-temp.tmpdir'
|
||||
await mkdir(join(dir, tempDirName))
|
||||
await writeFile(join(dir, tempDirName, 'PRECIOUS'), 'keep')
|
||||
await expect(
|
||||
writeFileAtomic(file, 'hello', undefined, undefined, { tempDirName: () => tempDirName }),
|
||||
).rejects.toMatchObject({ code: 'EEXIST' })
|
||||
expect(await readFile(join(dir, tempDirName, 'PRECIOUS'), 'utf8')).toBe('keep')
|
||||
await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('creates parent directories as needed', async () => {
|
||||
const file = join(dir, 'nested', 'deep', 'a.txt')
|
||||
await writeFileAtomic(file, 'hi', undefined, undefined)
|
||||
expect(await readFile(file, 'utf8')).toBe('hi')
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the write', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFileAtomic(file, 'hi', undefined, new AbortController().signal)
|
||||
expect(await readFile(file, 'utf8')).toBe('hi')
|
||||
})
|
||||
|
||||
it('aborts before writing when the signal is already aborted', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await expect(writeFileAtomic(file, 'hi', undefined, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('cleans up the temp file when the final rename fails', async () => {
|
||||
const sub = join(dir, 'occupied')
|
||||
await mkdir(sub)
|
||||
await expect(writeFileAtomic(sub, 'hi', undefined, undefined)).rejects.toBeInstanceOf(Error)
|
||||
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyLiteralEdit', () => {
|
||||
it('replaces a unique match', () => {
|
||||
expect(applyLiteralEdit('a b c', 'b', 'X', false, 'f')).toEqual({ content: 'a X c', replacements: 1 })
|
||||
})
|
||||
|
||||
it('rejects zero matches', () => {
|
||||
expect(() => applyLiteralEdit('a b c', 'z', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' }))
|
||||
})
|
||||
|
||||
it('rejects an empty oldString without scanning forever', () => {
|
||||
expect(() => applyLiteralEdit('a b c', '', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' }))
|
||||
})
|
||||
|
||||
it('rejects multiple matches without replaceAll', () => {
|
||||
expect(() => applyLiteralEdit('a a a', 'a', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_AMBIGUOUS_EDIT' }))
|
||||
})
|
||||
|
||||
it('replaces all matches with replaceAll', () => {
|
||||
expect(applyLiteralEdit('a a a', 'a', 'X', true, 'f')).toEqual({ content: 'X X X', replacements: 3 })
|
||||
})
|
||||
|
||||
it('matches across normalized line endings', () => {
|
||||
expect(applyLiteralEdit('one\ntwo', 'one\ntwo', 'x', false, 'f').replacements).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readForEdit + restoreLineEndings', () => {
|
||||
it('round-trips CRLF: matches on LF, writes back CRLF', async () => {
|
||||
const file = join(dir, 'crlf.txt')
|
||||
await writeFile(file, 'one\r\ntwo\r\n')
|
||||
const original = await readForEdit(file, file)
|
||||
expect(original.lineEndings).toBe('CRLF')
|
||||
const edited = applyLiteralEdit(original.content, 'two', 'TWO', false, file)
|
||||
expect(restoreLineEndings(edited.content, original.lineEndings)).toBe('one\r\nTWO\r\n')
|
||||
})
|
||||
|
||||
it('rejects a binary file and invalid UTF-8', async () => {
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01]))
|
||||
await expect(readForEdit(join(dir, 'bin'), join(dir, 'bin'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
await expect(readForEdit(join(dir, 'bad'), join(dir, 'bad'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the read', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const original = await readForEdit(file, file, new AbortController().signal)
|
||||
expect(original.content).toBe('one\ntwo')
|
||||
})
|
||||
|
||||
it('translates a mid-read AbortError into FS_ABORTED', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const ac = new AbortController()
|
||||
// Abort after the synchronous entry check, while readFile is pending.
|
||||
const pending = readForEdit(file, file, ac.signal)
|
||||
ac.abort()
|
||||
await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
15
packages/fs/fs-local/tsconfig.json
Normal file
15
packages/fs/fs-local/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": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../fs" }
|
||||
]
|
||||
}
|
||||
48
packages/fs/fs-policy/README.md
Normal file
48
packages/fs/fs-policy/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# @deepseek-ai/dsh-fs-policy
|
||||
|
||||
The **fs-policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
|
||||
declare const ctx: Context
|
||||
|
||||
// No service to inject — this plugin only registers the three fs/* listeners.
|
||||
// Load it alongside a ctx.fs provider (e.g. @deepseek-ai/dsh-fs-local) and the
|
||||
// @deepseek-ai/dsh-tool-fs tools; the tools dispatch the fs/* events this plugin
|
||||
// decides. Order does not matter for resolution (no inject), but the policy
|
||||
// listener should be the first decider registered for the fs/*-intent slots.
|
||||
await ctx.plugin(FsPolicy)
|
||||
```
|
||||
|
||||
## The four-layer split
|
||||
|
||||
| Layer | Package | Role |
|
||||
|---|---|---|
|
||||
| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events |
|
||||
| policy | `@deepseek-ai/dsh-fs-policy` (this) | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) |
|
||||
| provider seam | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary |
|
||||
| provider | `@deepseek-ai/dsh-fs-local` | local implementation of `ctx.fs` |
|
||||
|
||||
## How the gate participates
|
||||
|
||||
Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek-ai/dsh-tool-fs`):
|
||||
|
||||
| Event | This plugin's listener |
|
||||
|---|---|
|
||||
| `fs/write-intent` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. |
|
||||
| `fs/edit-intent` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. |
|
||||
| `fs/observed` | Records `{ version }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. |
|
||||
|
||||
## Observed state is the prior-observation record; freshness is provider CAS
|
||||
|
||||
Observed state is a `WeakMap<owner, Map<targetKey, FsVersion>>`. An entry exists **iff** the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence is the prior-observation record — there is no `hasRead` flag and no `full`/`partial` view. This plugin does **no** filesystem I/O: "have you observed this file?" is a `WeakMap` lookup, and "is the version you read still current?" is decided inside `ctx.fs.editText`/`writeText` in the same atomic lock that performs the mutation — this plugin only supplies `vObserved` as the basis. A windowed read of lines 100-150 records the file's version, and a later edit of line 120 is authorized as long as the file is unchanged. State is held weakly and dropped on disposal (HMR safety); persistence across sessions is deferred.
|
||||
|
||||
## Single-slot, first-wins
|
||||
|
||||
The `fs/write-intent`/`fs/edit-intent` slots hold exactly one decider — this plugin fully decides and does not call `next()`. The slot is first-wins by registration order; this plugin owning it is the default-deployment convention, not an event-enforced invariant (a decider registered before / `prepend`ed would win instead). This is not a composable authorization chain — layered permission/audit/sandbox interception belongs on `tools/execute`.
|
||||
|
||||
## No method coupling
|
||||
|
||||
Because the plugin influences the world only through events, removing it does not break `@deepseek-ai/dsh-tool-fs` at a service-injection boundary: the tool falls through to the bare `ctx.fs` provider (unconditional write/edit, no observed-state). Loading it back layers the policy on. That graceful add/remove is the whole point of the event gate over a mandatory method service.
|
||||
33
packages/fs/fs-policy/package.json
Normal file
33
packages/fs/fs-policy/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-fs-policy",
|
||||
"description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (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-fs": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
160
packages/fs/fs-policy/src/index.ts
Normal file
160
packages/fs/fs-policy/src/index.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The fs-policy PLUGIN: observed-state, read-before-edit, and
|
||||
* "write/edit must be based on the version you read" — added on top of the
|
||||
* `ctx.fs` provider seam through the `fs/*` event gate, NOT through a method
|
||||
* service. This plugin registers NO `ctx.fsPolicy` service and exposes no
|
||||
* `read`/`write`/`edit`/`resolve` methods; it influences the world only by
|
||||
* deciding the `fs/write-intent`/`fs/edit-intent` waterfalls and
|
||||
* recording on `fs/observed`. That is what keeps `@deepseek-ai/dsh-tool-fs`
|
||||
* (the executor) free of any method coupling to the policy layer — removing
|
||||
* this plugin gracefully loses the policy and leaves the unconstrained bare
|
||||
* provider, rather than breaking the tool at a service-injection boundary.
|
||||
*
|
||||
* ## Observed state IS the prior-observation record
|
||||
*
|
||||
* State lives here as `WeakMap<owner, Map<targetKey, { version }>>`. An entry
|
||||
* exists iff the owner has read, written, OR edited that target (every success
|
||||
* emits `fs/observed`), so its presence means "this owner has observed this
|
||||
* target at this version". This is what lets a create-then-edit or
|
||||
* edit-then-edit sequence work without an intervening re-read: the mutation
|
||||
* refreshes the recorded version to its own result. The owner is derived
|
||||
* structurally from `{ agent?: { session? } }` and held weakly, so a collected
|
||||
* session frees its state; disposal drops everything (HMR safety).
|
||||
*
|
||||
* ## Freshness via provider CAS, not stat
|
||||
*
|
||||
* This plugin does NO filesystem I/O. "Have you observed this file?" is a
|
||||
* `WeakMap` lookup (no record ⇒ `FS_NOT_OBSERVED`). "Is the version you read
|
||||
* still current?" is decided INSIDE `ctx.fs.editText`/`writeText`, in the same
|
||||
* atomic lock that performs the mutation — this plugin only supplies the
|
||||
* observed version as the CAS basis. Stat-ing and comparing here would open a
|
||||
* TOCTOU gap the provider lock has to back up anyway, so it is deliberately
|
||||
* avoided.
|
||||
*
|
||||
* ## Single-slot, first-wins
|
||||
*
|
||||
* The `fs/write-intent`/`fs/edit-intent` listeners do NOT call
|
||||
* `next()`: each fully decides its single slot. The slot is first-wins by
|
||||
* registration order — this plugin owning it is the default-deployment
|
||||
* convention, not an event-enforced invariant (a decider registered before /
|
||||
* `prepend`ed would win instead). This is not a composable authorization chain;
|
||||
* layered permission/audit/sandbox interception belongs on `tools/execute`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-policy
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsPolicyExec } from './types.ts'
|
||||
|
||||
export type { FsPolicyExec } from './types.ts'
|
||||
|
||||
/**
|
||||
* Per-context observed-file state and the three `fs/*` decisions over it. One
|
||||
* instance is created per `apply()` so disposal can drop all state for HMR.
|
||||
*/
|
||||
class ObservedStateGate {
|
||||
/**
|
||||
* Observed-file state, keyed first by the owner object (weakly held, so a
|
||||
* collected session frees its state), then by {@link FsTarget.targetKey}. An
|
||||
* entry's PRESENCE is the prior-observation record.
|
||||
*/
|
||||
private observed = new WeakMap<object, Map<string, FsVersion>>()
|
||||
|
||||
/**
|
||||
* Derive the observed-state owner from the opaque event actor — normally the
|
||||
* active agent session. `undefined` when no owner can be derived (e.g. a
|
||||
* direct tool call with no agent); such calls read freely but cannot satisfy
|
||||
* the write/edit prior-observation policy.
|
||||
*/
|
||||
private owner(actor: object | undefined): object | undefined {
|
||||
return (actor as FsPolicyExec | undefined)?.agent?.session
|
||||
}
|
||||
|
||||
private get(owner: object, targetKey: string): FsVersion | undefined {
|
||||
return this.observed.get(owner)?.get(targetKey)
|
||||
}
|
||||
|
||||
private set(owner: object, targetKey: string, version: FsVersion): void {
|
||||
let byTarget = this.observed.get(owner)
|
||||
if (!byTarget) {
|
||||
byTarget = new Map()
|
||||
this.observed.set(owner, byTarget)
|
||||
}
|
||||
byTarget.set(targetKey, version)
|
||||
}
|
||||
|
||||
/** Drop all recorded state (HMR safety / disposal). */
|
||||
clear(): void {
|
||||
this.observed = new WeakMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the write intent: no prior observation ⇒ `createIfAbsent` (only
|
||||
* new files can be created blindly); a prior observation ⇒ `replaceIfVersion`
|
||||
* at the observed version (existing files replaced only if unchanged).
|
||||
*/
|
||||
writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent {
|
||||
const owner = this.owner(actor)
|
||||
const prior = owner ? this.get(owner, target.targetKey) : undefined
|
||||
return prior ? { kind: 'replaceIfVersion', version: prior } : { kind: 'createIfAbsent' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the edit version guard: requires a prior observation by this owner
|
||||
* (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis.
|
||||
*/
|
||||
editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {
|
||||
const owner = this.owner(actor)
|
||||
const prior = owner ? this.get(owner, target.targetKey) : undefined
|
||||
if (!owner || !prior) {
|
||||
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
|
||||
}
|
||||
return { version: prior }
|
||||
}
|
||||
|
||||
/** Record a successful read/write/edit: this owner observed this target at this version. */
|
||||
observe(target: FsTarget, version: FsVersion, actor: object | undefined): void {
|
||||
const owner = this.owner(actor)
|
||||
if (owner) this.set(owner, target.targetKey, version)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'fs-policy'
|
||||
|
||||
/**
|
||||
* Register the three `fs/*` listeners. No `inject` — this plugin reads no
|
||||
* services; it operates only on its own `WeakMap`. The waterfalls are unbound
|
||||
* (the tool dispatches them with no `this`), so the listeners take the raw
|
||||
* `(target, actor, next)` arguments.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const gate = new ObservedStateGate()
|
||||
|
||||
ctx.effect(() => () => {
|
||||
// Drop all recorded state on disposal so a reloaded plugin starts clean
|
||||
// (HMR safety). The WeakMap itself would be GC'd, but replacing it makes the
|
||||
// release observable and immediate for tests.
|
||||
gate.clear()
|
||||
}, 'fs-policy observed-state teardown')
|
||||
|
||||
// fs/write-intent: occupy the single decision slot — do NOT call next().
|
||||
// Deferred through Promise.resolve().then so the declared Promise return type
|
||||
// holds (a throw rejects, never escapes synchronously through the waterfall).
|
||||
ctx.on('fs/write-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor)))
|
||||
|
||||
// fs/edit-intent: occupy the single decision slot — do NOT call next().
|
||||
// Deferred the same way so an FS_NOT_OBSERVED throw becomes a rejected promise
|
||||
// the edit tool's `await ctx.waterfall(...)` surfaces as its isError result.
|
||||
ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor)))
|
||||
|
||||
// fs/observed: synchronous, side-effect-only WeakMap write. The tool emits
|
||||
// this with a plain (unguarded) ctx.emit, so this listener MUST NOT throw —
|
||||
// a throw would surface as the tool's isError result for a mutation that
|
||||
// already succeeded. A WeakMap.set honors that contract.
|
||||
ctx.on('fs/observed', (target, version, actor) => {
|
||||
gate.observe(target, version, actor)
|
||||
})
|
||||
}
|
||||
29
packages/fs/fs-policy/src/types.ts
Normal file
29
packages/fs/fs-policy/src/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Vocabulary for the fs-policy plugin: the minimal execution-context
|
||||
* shape used to derive an observed-state owner by narrowing the opaque `object`
|
||||
* actor the `fs/*` events carry.
|
||||
*
|
||||
* The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is
|
||||
* re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state
|
||||
* owner structure on top of it.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-policy/types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal structural view of a tool execution the policy plugin needs to derive
|
||||
* an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies
|
||||
* this shape, so the tool passes its `exec` straight through as the opaque
|
||||
* `object` actor on the `fs/*` events; this plugin narrows that actor to this
|
||||
* shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`.
|
||||
*
|
||||
* The owner is `agent.session` when present. It is treated as an opaque object
|
||||
* identity (a `WeakMap` key); this package never reads any of its fields.
|
||||
*/
|
||||
export interface FsPolicyExec {
|
||||
/** The agent on whose behalf the call runs, when there is one. */
|
||||
agent?: {
|
||||
/** The session that owns observed-file state, used as an opaque key. */
|
||||
session?: object
|
||||
}
|
||||
}
|
||||
215
packages/fs/fs-policy/tests/policy.spec.ts
Normal file
215
packages/fs/fs-policy/tests/policy.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Tests for the fs-policy PLUGIN: it registers no service, only the
|
||||
* three `fs/*` listeners. We dispatch those events directly (the unbound
|
||||
* waterfalls the tool would dispatch, and the `fs/observed` emit) and assert the
|
||||
* decisions: createIfAbsent vs replaceIfVersion, FS_NOT_OBSERVED for an unread
|
||||
* edit, observed-state-as-prior-observation (read/write/edit all record),
|
||||
* multi-owner isolation, single-slot first-wins, and disposal/HMR release.
|
||||
*
|
||||
* No `ctx.fs` provider is needed — the plugin does no filesystem I/O; it only
|
||||
* decides intents and records versions on its own WeakMap.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy'
|
||||
|
||||
function target(path: string): FsTarget {
|
||||
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
|
||||
}
|
||||
const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } })
|
||||
|
||||
/** Dispatch the write-intent waterfall with the bare default thunk. */
|
||||
function writeIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<FsWriteIntent | undefined> {
|
||||
return ctx.waterfall('fs/write-intent', t, actor, () => undefined)
|
||||
}
|
||||
/** Dispatch the edit-intent waterfall with the bare default thunk. */
|
||||
function editIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> {
|
||||
return ctx.waterfall('fs/edit-intent', t, actor, () => undefined)
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(FsPolicy)
|
||||
return { ctx, fiber }
|
||||
}
|
||||
|
||||
describe('registration / disposal', () => {
|
||||
it('registers no service surface (it is a plugin, not ctx.fsPolicy)', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect((ctx as Context & { fsPolicy?: unknown }).fsPolicy).toBeUndefined()
|
||||
})
|
||||
|
||||
it('mounts with no inject (reads no services)', async () => {
|
||||
// It mounts immediately even with nothing else in the context.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FsPolicy)
|
||||
// The listener is live: an unobserved write decides createIfAbsent.
|
||||
expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('write-intent decision', () => {
|
||||
it('an unobserved target decides createIfAbsent', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
|
||||
it('a no-owner actor decides createIfAbsent', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
|
||||
expect(await writeIntent(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
|
||||
it('an actor with an agent but no session has no owner (createIfAbsent)', async () => {
|
||||
// The middle optional-chain rung: agent present, session undefined ⇒ owner
|
||||
// undefined ⇒ unobservable, so a write can only be a blind create.
|
||||
const { ctx } = await setup()
|
||||
expect(await writeIntent(ctx, target('a.txt'), { agent: {} })).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
|
||||
it('an observed target decides replaceIfVersion at the observed version', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v7'), exec)
|
||||
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit-intent decision', () => {
|
||||
it('rejects an unread edit with FS_NOT_OBSERVED', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('rejects an edit with no owner (cannot prove prior observation)', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(editIntent(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('rejects an edit whose actor has an agent but no session (no owner)', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(editIntent(ctx, target('a.txt'), { agent: {} })).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('returns the observed version as the CAS basis after an observation', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v3'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('observed-state is the prior-observation record', () => {
|
||||
it('a read observation authorizes an in-place write at that version', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read
|
||||
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
|
||||
})
|
||||
|
||||
it('a write/edit observation refreshes the basis, so the next edit needs no re-read', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
// A create records v1; the follow-up edit guards against v1 with no read.
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v1'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' })
|
||||
// The edit records v2; a second edit guards against v2.
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v2'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' })
|
||||
})
|
||||
|
||||
it('a no-owner observation records nothing', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), undefined)
|
||||
// Still unobserved for any owner.
|
||||
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-owner isolation', () => {
|
||||
it('owner A observing does not grant owner B edit authority', async () => {
|
||||
const { ctx } = await setup()
|
||||
const a = ownerExec({})
|
||||
const b = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a)
|
||||
await expect(editIntent(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await editIntent(ctx, target('a.txt'), a)).toEqual({ version: 'v0' })
|
||||
})
|
||||
|
||||
it('each owner records its own observed version independently', async () => {
|
||||
const { ctx } = await setup()
|
||||
const a = ownerExec({})
|
||||
const b = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) // A observed v0
|
||||
// B never observed → createIfAbsent; A still holds v0 → replaceIfVersion.
|
||||
expect(await writeIntent(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' })
|
||||
expect(await writeIntent(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('single-slot, first-wins', () => {
|
||||
it('fully decides the slot without calling next() (the bare default is unreached)', async () => {
|
||||
const { ctx } = await setup()
|
||||
let defaultRan = false
|
||||
const intent = await ctx.waterfall('fs/write-intent', target('a.txt'), ownerExec({}), () => {
|
||||
defaultRan = true
|
||||
return undefined
|
||||
})
|
||||
expect(intent).toEqual({ kind: 'createIfAbsent' })
|
||||
expect(defaultRan).toBe(false)
|
||||
})
|
||||
|
||||
it('a SECOND decider registered AFTER fs-policy is not reached (first-wins short-circuit)', async () => {
|
||||
const { ctx } = await setup()
|
||||
let secondRan = false
|
||||
// Registered after fs-policy, so it dispatches second; fs-policy does
|
||||
// not call next(), so this never runs. (A decider registered BEFORE — or with
|
||||
// prepend — would instead win: first-wins is by convention, not enforced.)
|
||||
ctx.on('fs/edit-intent', () => {
|
||||
secondRan = true
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
|
||||
await editIntent(ctx, target('a.txt'), exec)
|
||||
expect(secondRan).toBe(false)
|
||||
})
|
||||
|
||||
it('a SECOND write-intent decider registered AFTER fs-policy is not reached', async () => {
|
||||
const { ctx } = await setup()
|
||||
let secondRan = false
|
||||
ctx.on('fs/write-intent', () => {
|
||||
secondRan = true
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
await writeIntent(ctx, target('a.txt'), ownerExec({}))
|
||||
expect(secondRan).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposal releases recorded state (HMR safety)', () => {
|
||||
it('a fresh plugin after disposal starts with no inherited state', async () => {
|
||||
const ctx = new Context()
|
||||
const exec = ownerExec({})
|
||||
const fiber = await ctx.plugin(FsPolicy)
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' })
|
||||
await fiber.dispose()
|
||||
|
||||
await ctx.plugin(FsPolicy)
|
||||
// Same owner object, but state was released on disposal.
|
||||
await expect(editIntent(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('no listeners remain after disposal (the gate no longer decides)', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(FsPolicy)
|
||||
await fiber.dispose()
|
||||
// With no listener, the waterfall falls through to the bare default.
|
||||
expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
14
packages/fs/fs-policy/tsconfig.json
Normal file
14
packages/fs/fs-policy/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": "../../llm/llm" },
|
||||
{ "path": "../fs" }
|
||||
]
|
||||
}
|
||||
43
packages/fs/fs/README.md
Normal file
43
packages/fs/fs/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# @deepseek-ai/dsh-fs
|
||||
|
||||
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the text-storage primitives a backend provides — resolve a path, stat metadata, read/stream text, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
|
||||
|
||||
This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate RFC](../../../docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
|
||||
|
||||
| Layer | Package | Role |
|
||||
|---|---|---|
|
||||
| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing `read`/`write`/`edit` schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events |
|
||||
| policy | `@deepseek-ai/dsh-fs-policy` | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) |
|
||||
| provider seam | `@deepseek-ai/dsh-fs` (this) | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary |
|
||||
| provider | `@deepseek-ai/dsh-fs-local` | the host-filesystem implementation |
|
||||
|
||||
A future sandboxed, virtual, or remote backend implements this interface and the policy/tool layers don't change.
|
||||
|
||||
## Service API (`ctx.fs`)
|
||||
|
||||
A backend subclasses `FileSystem` and implements six primitives.
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
|
||||
| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. |
|
||||
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
|
||||
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). |
|
||||
| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. |
|
||||
| `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. |
|
||||
|
||||
The mutation runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic — "unconditional" drops the *version* precondition, not the atomicity.
|
||||
|
||||
## The `fs/*` policy events
|
||||
|
||||
This package declares three events (see the generated [catalog](../../../docs/cordis-catalog/events-and-services.md)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
|
||||
|
||||
## A provider seam, not the policy layer
|
||||
|
||||
`ctx.fs` is deliberately close to fsspec-style storage primitives — half a level above byte-level `cat`/`open`, because it decodes text and rejects binaries so the policy layer never touches raw bytes. It owns UTF-8 decoding, binary rejection, atomic writes, and the literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state. Observed-state, read-before-edit, and version-guarded write/edit are policy a plugin (`@deepseek-ai/dsh-fs-policy`) ADDS by supplying the optional guard — not provider behavior — so a sandboxed/remote backend inherits no model-facing observation policy.
|
||||
|
||||
`editText` stays on this seam (not composed in the policy layer from a read plus a write) because version guard + literal match + atomic rewrite must stay inside one critical section for correct error attribution and one-wins/one-stale concurrency, and a remote backend may implement it as a native compare-and-edit.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
|
||||
34
packages/fs/fs/package.json
Normal file
34
packages/fs/fs/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-fs",
|
||||
"description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary",
|
||||
"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",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
211
packages/fs/fs/src/index.ts
Normal file
211
packages/fs/fs/src/index.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* The filesystem provider seam (`ctx.fs`): an abstract service defining the
|
||||
* text-storage primitives a backend provides — resolve a path into a stable
|
||||
* target, stat its metadata, read/stream its text, write it atomically with an
|
||||
* explicit intent, and apply a guarded literal edit — without saying HOW.
|
||||
* Implementations subclass {@link FileSystem} and register themselves as the
|
||||
* `fs` service; `@deepseek-ai/dsh-fs-local` (the host filesystem) is the first.
|
||||
* Future implementations swap in sandboxed, remote, virtual, or project-scoped
|
||||
* backends without touching the model-facing tool schemas
|
||||
* (`@deepseek-ai/dsh-tool-fs`).
|
||||
*
|
||||
* The split mirrors the bash seam (`BashExecutor`/`LocalBashExecutor`). See the
|
||||
* capability-seam RFC for why a swappable capability is three (here four)
|
||||
* packages.
|
||||
*
|
||||
* ## This is a provider seam, not the policy layer
|
||||
*
|
||||
* `ctx.fs` is deliberately close to fsspec-style storage primitives. It owns
|
||||
* UTF-8 decoding, binary/NUL rejection, atomic full-file writes, and the
|
||||
* literal-edit critical section — but NOT line windows, numbered lines,
|
||||
* rendered footers, or observed-state. Read windowing lives in the model-facing
|
||||
* tool (`@deepseek-ai/dsh-tool-fs`); observed-state and read-before-write/edit
|
||||
* are policy a plugin (`@deepseek-ai/dsh-fs-policy`) adds through the `fs/*`
|
||||
* event gate. So a sandboxed/remote backend inherits no model-facing observation
|
||||
* policy it has no business carrying.
|
||||
*
|
||||
* `editText` stays on this seam (not composed in the policy layer from a read
|
||||
* plus a write) because version guard + literal match + atomic rewrite must
|
||||
* stay inside one mutation critical section for correct error attribution and
|
||||
* one-wins/one-stale concurrency, and a remote backend may implement it as a
|
||||
* native compare-and-edit.
|
||||
*
|
||||
* ## The version guard is OPTIONAL — additive policy, not subtractive
|
||||
*
|
||||
* `ctx.fs` on its own is a complete, unconstrained text-storage seam: `read`
|
||||
* reads, `write` unconditionally creates-or-overwrites, `edit` unconditionally
|
||||
* replaces literal text in the current content. Both mutations take their
|
||||
* version guard as an OPTIONAL argument — omit it for the unconstrained
|
||||
* bare-provider behavior, supply it to guard against a concurrent change. The
|
||||
* mutation runs inside the backend's per-target lock either way, so an
|
||||
* unconditional write/edit is still atomic; "unconditional" drops the *version*
|
||||
* precondition, not the atomicity. Observed-state, read-before-edit, and
|
||||
* version-guarded write/edit are NOT provider behavior — they are policy a
|
||||
* plugin (`@deepseek-ai/dsh-fs-policy`) adds on top by supplying the guard.
|
||||
*
|
||||
* ## The fs policy events live here, not in the policy plugin
|
||||
*
|
||||
* This package owns the `fs/write-intent`, `fs/edit-intent`, and
|
||||
* `fs/observed` event vocabulary (see {@link Events}). The emitter is
|
||||
* `@deepseek-ai/dsh-tool-fs` and the default listener is
|
||||
* `@deepseek-ai/dsh-fs-policy`; the events live in the one package both
|
||||
* already depend on, so the emitter shares a vocabulary with the policy listener
|
||||
* without depending on the policy plugin. The events carry only `dsh-fs`
|
||||
* vocabulary plus an opaque `object` actor — no model-facing concepts (line
|
||||
* windows, numbered lines) and no agent/session owner structure leak down.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsVersion,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from './types.ts'
|
||||
|
||||
export {
|
||||
FsError,
|
||||
FsTargetKey,
|
||||
FsVersion,
|
||||
} from './types.ts'
|
||||
export type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsErrorCode,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
fs: FileSystem
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Single-slot decision: produce the write intent for the next
|
||||
* {@link FileSystem.writeText}. The tool dispatches this as an unbound
|
||||
* waterfall (no `this`) and supplies a default thunk returning `undefined`
|
||||
* (unconditional create-or-overwrite — the bare provider). The
|
||||
* `@deepseek-ai/dsh-fs-policy` policy listener returns `createIfAbsent`
|
||||
* (unobserved actor) or `{ kind: 'replaceIfVersion', version: vObserved }`
|
||||
* (observed) and does NOT call `next()` — one decision, not a composable
|
||||
* chain. The slot is first-wins: the first non-`next()` decider (registration
|
||||
* order, or `prepend`) occupies it; a second decider is a misconfiguration,
|
||||
* not layering. `actor` is the opaque tool-execution context, never read here.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
|
||||
/**
|
||||
* Single-slot decision: produce the optional version guard for the next
|
||||
* {@link FileSystem.editText}. The tool dispatches this as an unbound
|
||||
* waterfall and supplies a default thunk returning `undefined` (unconditional
|
||||
* edit of the current content — the bare provider; no `stat`). The
|
||||
* `@deepseek-ai/dsh-fs-policy` policy listener returns
|
||||
* `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset
|
||||
* or has not observed the target. Does NOT call `next()`: one decision,
|
||||
* first-wins (see {@link Events.'fs/write-intent'}).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
|
||||
/**
|
||||
* Record that an actor observed a target at a version, after a successful
|
||||
* read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be a
|
||||
* synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s
|
||||
* is a `WeakMap.set`): the tool does not guard the emit, so a listener that
|
||||
* throws surfaces as the tool's `isError` result, and cordis `emit` does not
|
||||
* await listener promises — async or fallible audit/telemetry does not
|
||||
* belong here. No listener ⇒ nothing recorded. `actor` is the opaque
|
||||
* tool-execution context.
|
||||
* @mode emit
|
||||
*/
|
||||
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract filesystem provider service. Subclass, implement the six text-storage
|
||||
* primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one
|
||||
* implementation per context; loading a second throws, cordis' standard
|
||||
* duplicate-service behavior).
|
||||
*
|
||||
* Semantics every backend must honor:
|
||||
* - {@link resolve} returns a stable {@link FsTarget}; the same underlying file
|
||||
* reached by different input paths must yield the same `targetKey` so stale
|
||||
* guards and target lookup agree across paths (e.g. through symlinks).
|
||||
* - {@link stat} returns {@link FsInfo} metadata (never content) or `undefined`
|
||||
* when the target is absent.
|
||||
* - {@link readText}/{@link streamText} read the whole regular text file (the
|
||||
* stream for large files); both own regular-file checks, UTF-8 decoding,
|
||||
* binary/NUL rejection, and `FS_NOT_TEXT`.
|
||||
* - {@link writeText} is atomic temp-file + rename. `expected` is OPTIONAL:
|
||||
* omit it for an unconditional create-or-overwrite (the bare-provider default),
|
||||
* or supply a {@link FsWriteIntent} to guard the write.
|
||||
* - {@link editText} verifies `expected.version` BEFORE literal matching (so a
|
||||
* stale edit reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND`/
|
||||
* `FS_AMBIGUOUS_EDIT` against newer content), then applies literal replacement
|
||||
* and writes atomically — all inside one mutation critical section. `expected`
|
||||
* is OPTIONAL: omit it for an unconditional edit of the current content (a
|
||||
* missing target still reports `FS_STALE_VERSION`).
|
||||
*/
|
||||
export abstract class FileSystem extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'fs')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May
|
||||
* perform I/O (a remote/sandboxed backend may need a round-trip to map a path
|
||||
* to a stable identity), hence async even though the local backend only
|
||||
* normalizes + realpaths.
|
||||
*
|
||||
* `opts.cwd` is the base directory a RELATIVE `path` resolves against; an
|
||||
* absolute `path` ignores it. Omitted ⇒ the backend's own default base (the
|
||||
* local backend uses its configured `cwd`). The CALLER supplies this — the
|
||||
* seam does not read a session or agent — so a tool can resolve against the
|
||||
* caller's per-session workspace (`exec.agent.session.header.cwd`) without the
|
||||
* provider depending on `dsh-agent`/`dsh-session`. Mirrors how `dsh-tool-bash`
|
||||
* defaults a bash `workdir` to the session cwd.
|
||||
*/
|
||||
abstract resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>
|
||||
|
||||
/** Return target metadata, or `undefined` when the target does not exist. */
|
||||
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
|
||||
|
||||
/** Read the whole regular text file as a single decoded string. */
|
||||
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
|
||||
|
||||
/**
|
||||
* Stream the whole regular text file as decoded text chunks (same text
|
||||
* semantics as {@link readText}, for large files). The backend owns
|
||||
* cross-chunk UTF-8 decoding and binary rejection so the policy layer never
|
||||
* touches raw bytes.
|
||||
*/
|
||||
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
|
||||
|
||||
/**
|
||||
* Create or fully replace a UTF-8 text file atomically. `expected` is the
|
||||
* create-vs-replace decision and stale guard when supplied; OMITTING it is an
|
||||
* unconditional create-or-overwrite (the bare provider — no version guard, no
|
||||
* read-first requirement). Atomic either way.
|
||||
*/
|
||||
abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
|
||||
|
||||
/**
|
||||
* Apply a literal edit to an existing UTF-8 text file. When `expected` is
|
||||
* supplied, verifies `expected.version` as the stale guard BEFORE literal
|
||||
* matching; OMITTING it edits the current content unconditionally (no version
|
||||
* guard). Either way applies the replacement and writes atomically — one
|
||||
* mutation critical section — and a missing target reports `FS_STALE_VERSION`.
|
||||
*/
|
||||
abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
|
||||
}
|
||||
|
||||
export default FileSystem
|
||||
154
packages/fs/fs/src/types.ts
Normal file
154
packages/fs/fs/src/types.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque
|
||||
* target/version identities, the metadata `stat` returns, the write-intent
|
||||
* and outcome shapes, the literal-edit request/outcome, and the typed error
|
||||
* taxonomy.
|
||||
*
|
||||
* These types are shared by every backend (`@deepseek-ai/dsh-fs-local` and
|
||||
* future sandboxed/remote backends) and by the policy layer
|
||||
* (`@deepseek-ai/dsh-fs-policy`). They are deliberately a *text-storage*
|
||||
* vocabulary half a level above byte-level fsspec: `readText`/`streamText` hand
|
||||
* back decoded text, never raw bytes. Host-path assumptions stay out — `targetKey`
|
||||
* and `version` are opaque branded tokens, and `displayPath` is the only field a
|
||||
* consumer may show.
|
||||
*
|
||||
* Model-facing concepts (line windows, numbered lines, observed-state) do NOT
|
||||
* live here; they belong to the consumer tool and the policy plugin
|
||||
* (`@deepseek-ai/dsh-tool-fs` / `@deepseek-ai/dsh-fs-policy`).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs/types
|
||||
*/
|
||||
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/**
|
||||
* Opaque key for stale guards and target lookup. The local backend uses a
|
||||
* realpath-like string; a remote backend might use a workspace URI or file id.
|
||||
* Consumers MUST NOT parse it or assume it is a local absolute path.
|
||||
*/
|
||||
export type FsTargetKey = Branded<'FsTargetKey'>
|
||||
|
||||
/** Brand a string as an {@link FsTargetKey}. */
|
||||
export function FsTargetKey(key: string): FsTargetKey {
|
||||
return key as FsTargetKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Opaque file-version token — the freshness token a write/edit guards against.
|
||||
* The local backend derives it from mtime+size; a remote backend might use a
|
||||
* revision id. The policy layer records it for stale checks; consumers may
|
||||
* display related metadata but MUST NOT interpret this token.
|
||||
*/
|
||||
export type FsVersion = Branded<'FsVersion'>
|
||||
|
||||
/** Brand a string as an {@link FsVersion}. */
|
||||
export function FsVersion(v: string): FsVersion {
|
||||
return v as FsVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* A path resolved by a backend into a stable identity. `resolve()` produces
|
||||
* this; every other operation takes it.
|
||||
*/
|
||||
export interface FsTarget {
|
||||
/** The original model/plugin-supplied path, for diagnostics only. */
|
||||
inputPath: string
|
||||
/** Opaque key for stale guards and target lookup. */
|
||||
targetKey: FsTargetKey
|
||||
/**
|
||||
* Path for model/UI-facing output. May be a local absolute path,
|
||||
* workspace-relative path, or remote URI depending on the backend.
|
||||
*/
|
||||
displayPath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata about a target — what {@link FileSystem.stat} returns. Lets the
|
||||
* policy layer reject directories/special files before reading and choose
|
||||
* `readText` vs `streamText` from `size` without probing by failure. `version`
|
||||
* is the freshness token. `undefined` from `stat` means the target is absent.
|
||||
*/
|
||||
export interface FsInfo {
|
||||
/** Opaque freshness token of the target right now. */
|
||||
version: FsVersion
|
||||
/** Whether the target is a regular file, a directory, or something else. */
|
||||
type: 'file' | 'directory' | 'other'
|
||||
/** Byte size of a regular file, when the backend can report it. */
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The explicit intent of a guarded {@link FileSystem.writeText} call.
|
||||
* `createIfAbsent` creates a missing target and rejects an existing one with
|
||||
* `FS_NOT_OBSERVED` (the path the policy plugin uses when the owner has no prior
|
||||
* read). `replaceIfVersion` replaces only when the target exists at the observed
|
||||
* version; a missing target or a version mismatch throws `FS_STALE_VERSION`.
|
||||
*
|
||||
* `writeText` takes this OPTIONALLY: omitting `expected` is the third,
|
||||
* unconstrained state — an unconditional create-or-overwrite (the bare
|
||||
* provider). The union itself carries only the two GUARDED intents; "no guard"
|
||||
* is expressed by omission, so the write and edit mutations share one symmetric
|
||||
* shape (`expected?`: omit = unconditional, present = guarded).
|
||||
*/
|
||||
export type FsWriteIntent =
|
||||
| { kind: 'createIfAbsent' }
|
||||
| { kind: 'replaceIfVersion'; version: FsVersion }
|
||||
|
||||
/** Outcome of a full-file write. */
|
||||
export interface FsWriteOutcome {
|
||||
/** Whether the write created a new file or replaced an existing one. */
|
||||
operation: 'create' | 'update'
|
||||
/** Opaque version of the file after the write. */
|
||||
version: FsVersion
|
||||
}
|
||||
|
||||
/** A literal-replacement edit request. */
|
||||
export interface FsEditRequest {
|
||||
/** Literal non-empty text to replace. Must match exactly (after line-ending normalization). */
|
||||
oldString: string
|
||||
/** Literal replacement text. An empty string deletes the matched text. */
|
||||
newString: string
|
||||
/** Replace every match instead of requiring exactly one. */
|
||||
replaceAll: boolean
|
||||
}
|
||||
|
||||
/** Outcome of a literal edit. */
|
||||
export interface FsEditOutcome {
|
||||
/** Number of literal replacements applied. */
|
||||
replacements: number
|
||||
/** Whether every match was replaced. */
|
||||
replaceAll: boolean
|
||||
/** Opaque version of the file after the edit. */
|
||||
version: FsVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable, machine-routable codes for filesystem failures. Carried on
|
||||
* {@link FsError}; the tool registry surfaces `{ name, code }` on `isError`
|
||||
* results so retry/permission/UI layers can branch without parsing messages.
|
||||
*/
|
||||
export type FsErrorCode =
|
||||
| 'FS_NOT_FOUND'
|
||||
| 'FS_NOT_TEXT'
|
||||
| 'FS_NOT_REGULAR_FILE'
|
||||
| 'FS_STALE_VERSION'
|
||||
| 'FS_NOT_OBSERVED'
|
||||
| 'FS_AMBIGUOUS_EDIT'
|
||||
| 'FS_EDIT_NOT_FOUND'
|
||||
| 'FS_ABORTED'
|
||||
|
||||
/**
|
||||
* Typed filesystem error. Extends {@link HarnessError} so it carries a stable
|
||||
* {@link FsErrorCode} and chains `cause`. `dsh-fs` owns this vocabulary so
|
||||
* backends and the policy layer raise the same codes instead of each inventing
|
||||
* message strings.
|
||||
*/
|
||||
export class FsError extends HarnessError {
|
||||
override readonly code: FsErrorCode
|
||||
|
||||
constructor(message: string, code: FsErrorCode, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
118
packages/fs/fs/tests/service.spec.ts
Normal file
118
packages/fs/fs/tests/service.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Tests for the filesystem provider seam itself: registration, duplicate-service
|
||||
* behavior, disposal, and the branded id factories. The provider primitives and
|
||||
* policy live in `dsh-fs-local` and `dsh-fs-policy`; this seam owns only the
|
||||
* abstract service contract, so a minimal fake backend exercises it.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** A minimal in-memory fake implementing the six provider primitives. */
|
||||
class FakeFileSystem extends FileSystem {
|
||||
files = new Map<string, string>()
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
|
||||
}
|
||||
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
|
||||
const content = this.files.get(target.targetKey)
|
||||
if (content === undefined) return undefined
|
||||
return { version: FsVersion('v1'), type: 'file', size: content.length }
|
||||
}
|
||||
override async readText(target: FsTarget): Promise<string> {
|
||||
const content = this.files.get(target.targetKey)
|
||||
if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND')
|
||||
return content
|
||||
}
|
||||
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
|
||||
const content = await this.readText(target)
|
||||
return (async function* () { yield content })()
|
||||
}
|
||||
override async writeText(target: FsTarget, content: string, _expected?: FsWriteIntent): Promise<FsWriteOutcome> {
|
||||
const existed = this.files.has(target.targetKey)
|
||||
this.files.set(target.targetKey, content)
|
||||
return { operation: existed ? 'update' : 'create', version: FsVersion('v2') }
|
||||
}
|
||||
override async editText(target: FsTarget, edit: FsEditRequest): Promise<FsEditOutcome> {
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString))
|
||||
return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') }
|
||||
}
|
||||
}
|
||||
|
||||
describe('FileSystem provider seam', () => {
|
||||
it('registers as ctx.fs and serves the primitives', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFileSystem)
|
||||
const fs = ctx.fs as FakeFileSystem
|
||||
fs.files.set('a.txt', 'hi')
|
||||
const target = await fs.resolve('a.txt')
|
||||
expect((await fs.stat(target))?.type).toBe('file')
|
||||
expect(await fs.readText(target)).toBe('hi')
|
||||
})
|
||||
|
||||
it('throws when a second implementation is loaded (duplicate service)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFileSystem)
|
||||
await expect(ctx.plugin(FakeFileSystem)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('removes the service when the providing fiber is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(FakeFileSystem)
|
||||
expect(ctx.fs).toBeDefined()
|
||||
await fiber.dispose()
|
||||
expect(ctx.fs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('streamText yields the same text readText returns', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFileSystem)
|
||||
const fs = ctx.fs as FakeFileSystem
|
||||
fs.files.set('a.txt', 'one\ntwo')
|
||||
const target = await fs.resolve('a.txt')
|
||||
let streamed = ''
|
||||
for await (const chunk of await fs.streamText(target)) streamed += chunk
|
||||
expect(streamed).toBe(await fs.readText(target))
|
||||
})
|
||||
|
||||
it('stat returns undefined for an absent target', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFileSystem)
|
||||
const fs = ctx.fs as FakeFileSystem
|
||||
expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('branded id factories', () => {
|
||||
it('FsTargetKey and FsVersion brand a string at compile time (identity at runtime)', () => {
|
||||
expect(FsTargetKey('k')).toBe('k')
|
||||
expect(FsVersion('v')).toBe('v')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FsError', () => {
|
||||
it('carries a stable code and HarnessError name', () => {
|
||||
const error = new FsError('nope', 'FS_NOT_FOUND')
|
||||
expect(error.code).toBe('FS_NOT_FOUND')
|
||||
expect(error.name).toBe('FsError')
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it('chains an underlying cause through ErrorOptions', () => {
|
||||
const root = new Error('EACCES')
|
||||
const error = new FsError('cannot read', 'FS_ABORTED', { cause: root })
|
||||
expect(error.cause).toBe(root)
|
||||
expect(error.code).toBe('FS_ABORTED')
|
||||
})
|
||||
})
|
||||
14
packages/fs/fs/tsconfig.json
Normal file
14
packages/fs/fs/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": "../../util/brand" },
|
||||
{ "path": "../../llm/llm" }
|
||||
]
|
||||
}
|
||||
38
packages/fs/tool-fs/README.md
Normal file
38
packages/fs/tool-fs/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# @deepseek-ai/dsh-tool-fs
|
||||
|
||||
The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it.
|
||||
|
||||
```ts ignore-check
|
||||
// Default deployment: a ctx.fs provider, the policy plugin, then the tools.
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local
|
||||
await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate)
|
||||
await ctx.plugin(ToolFs) // this package — registers read/write/edit
|
||||
```
|
||||
|
||||
`@deepseek-ai/dsh-fs-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit.
|
||||
|
||||
## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md))
|
||||
|
||||
| Tool | Arguments | Behavior |
|
||||
|---|---|---|
|
||||
| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at 2000 lines. |
|
||||
| `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. |
|
||||
| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. |
|
||||
|
||||
Field names are snake_case to match Claude Code and existing harness tool schemas.
|
||||
|
||||
## The tool is the executor; policy is an event gate
|
||||
|
||||
The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash` (see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then:
|
||||
|
||||
- **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 stat.)
|
||||
- **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.)
|
||||
- **edit** — `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, intent)`, then `fs/observed`. (0 stat.)
|
||||
|
||||
The tool passes `exec` (the tool-execution context) as the opaque `actor` on every dispatch. The default thunks return `undefined` (the unconstrained bare provider). When `@deepseek-ai/dsh-fs-policy` is loaded it occupies the single decision slot — returning `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED` — and records on `fs/observed`. Backend errors (`FsError`) and a thrown `FS_NOT_OBSERVED` flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached.
|
||||
|
||||
## `fs/observed` is fire-and-forget
|
||||
|
||||
`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event.
|
||||
|
||||
The read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them.
|
||||
44
packages/fs/tool-fs/package.json
Normal file
44
packages/fs/tool-fs/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-fs",
|
||||
"description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)",
|
||||
"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-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^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-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
100
packages/fs/tool-fs/src/edit.ts
Normal file
100
packages/fs/tool-fs/src/edit.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* The model-facing `edit` tool: update an existing UTF-8 text file by replacing
|
||||
* literal text, requiring a unique match by default. The tool is the executor:
|
||||
* it dispatches the `fs/edit-intent` waterfall to obtain the optional
|
||||
* version guard, calls `ctx.fs.editText` directly, and emits `fs/observed`. The
|
||||
* default thunk returns `undefined` (unconditional edit of the current content
|
||||
* — the bare provider); a policy plugin (`@deepseek-ai/dsh-fs-policy`)
|
||||
* occupies the single decision slot, returning `{ version: vObserved }` or
|
||||
* throwing `FS_NOT_OBSERVED` for an unread file. The tool stats ZERO times
|
||||
* either way; a missing target is reported by the provider as `FS_STALE_VERSION`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/src/edit
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { FsEditOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
|
||||
/** Validated `edit` arguments after defaulting. */
|
||||
interface EditInput {
|
||||
filePath: string
|
||||
oldString: string
|
||||
newString: string
|
||||
replaceAll: boolean
|
||||
}
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
export function parseEditArgs(args: { file_path: string; old_string: string; new_string: string; replace_all?: boolean }): EditInput {
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
if (args.old_string.length === 0) throw new Error('old_string must be a non-empty string')
|
||||
if (args.old_string === args.new_string) throw new Error('old_string and new_string must differ')
|
||||
return {
|
||||
filePath: args.file_path,
|
||||
oldString: args.old_string,
|
||||
newString: args.new_string,
|
||||
replaceAll: args.replace_all ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Format an edit outcome as a Claude-style model-facing success message. */
|
||||
export function formatEditOutput(displayPath: string, outcome: FsEditOutcome): string {
|
||||
return outcome.replaceAll
|
||||
? `The file ${displayPath} has been updated. All occurrences were successfully replaced.`
|
||||
: `The file ${displayPath} has been updated successfully.`
|
||||
}
|
||||
|
||||
/** Register the `edit` tool and its system-prompt guidance. */
|
||||
export function applyEditTool(ctx: Context): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:edit',
|
||||
order: 102,
|
||||
text: 'Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'edit',
|
||||
description: 'Edit an existing UTF-8 text file by replacing literal text.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: 'Path to edit, resolved by the filesystem backend.' },
|
||||
old_string: { type: 'string', required: true, description: 'Literal text to replace. Must match exactly.' },
|
||||
new_string: { type: 'string', required: true, description: 'Literal replacement text. Use an empty string to delete the match.' },
|
||||
replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' },
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseEditArgs(args)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
// Single-slot decision: the policy plugin returns { version: vObserved } or
|
||||
// throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
|
||||
// No stat — the bare default never manufactures a version basis.
|
||||
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
|
||||
const outcome = await ctx.fs.editText(
|
||||
target,
|
||||
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
|
||||
intent,
|
||||
exec.signal,
|
||||
)
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
return [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }]
|
||||
},
|
||||
// Pure display: `edit` kind, a location for editor follow-along, and a short
|
||||
// old→new summary as rawInput (truncated so a large replacement stays a
|
||||
// readable card). The replacement COUNT is not available here — presentResult
|
||||
// only sees `{ content, isError }`, not the outcome — so the title is static.
|
||||
presentCall(args) {
|
||||
const clip = (s: string): string => (s.length > 40 ? `${s.slice(0, 40)}…` : s)
|
||||
return {
|
||||
title: `Edit ${args.file_path}`,
|
||||
kind: 'edit',
|
||||
rawInput: `${JSON.stringify(clip(args.old_string))} → ${JSON.stringify(clip(args.new_string))}`,
|
||||
locations: [{ path: args.file_path }],
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
47
packages/fs/tool-fs/src/index.ts
Normal file
47
packages/fs/tool-fs/src/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* The model-facing filesystem tool suite (`read`, `write`, `edit`) over the
|
||||
* `ctx.fs` provider seam. This single plugin registers all three tools.
|
||||
*
|
||||
* ## The tool is the executor; policy is an event gate
|
||||
*
|
||||
* The tool reads/writes/edits through `ctx.fs` DIRECTLY and owns model-facing
|
||||
* concerns only — tool names, JSON schemas, argument validation, prompt
|
||||
* sections, read windowing, result formatting. It does NOT inject a policy
|
||||
* service. Instead, on each write/edit it dispatches a single-slot waterfall
|
||||
* (`fs/write-intent`/`fs/edit-intent`) to obtain the OPTIONAL version guard, and
|
||||
* after every read/write/edit it emits `fs/observed` with a plain (unguarded)
|
||||
* `ctx.emit`. A policy plugin (`@deepseek-ai/dsh-fs-policy`) occupies the
|
||||
* decision slot and listens for `fs/observed` to add observed-state +
|
||||
* read-before-edit + version-guarded write/edit; a deployment that loads these
|
||||
* tools is expected to also load it. With no policy plugin the waterfalls fall
|
||||
* through to their `undefined` default (the unconstrained bare provider) and
|
||||
* `fs/observed` is unheard — the tool still functions. This package never
|
||||
* imports `node:fs`, `node:path`, or an `@deepseek-ai/dsh-fs-local`
|
||||
* implementation.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { applyReadTool } from './read.ts'
|
||||
import { applyWriteTool } from './write.ts'
|
||||
import { applyEditTool } from './edit.ts'
|
||||
|
||||
export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, parseReadArgs } from './read.ts'
|
||||
export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts'
|
||||
export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts'
|
||||
export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow, formatReadOutput } from './read-render.ts'
|
||||
export type { FileReadOutcome, FileTextLine, ReadWindow, WindowResult } from './read-render.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'tool-fs'
|
||||
|
||||
/** Services required by the filesystem tool suite. */
|
||||
export const inject = ['tools', 'fs', 'systemPrompt']
|
||||
|
||||
/** Register the full `read`/`write`/`edit` filesystem tool suite. */
|
||||
export function apply(ctx: Context): void {
|
||||
applyReadTool(ctx)
|
||||
applyWriteTool(ctx)
|
||||
applyEditTool(ctx)
|
||||
}
|
||||
180
packages/fs/tool-fs/src/read-render.ts
Normal file
180
packages/fs/tool-fs/src/read-render.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Cordis-free read rendering for `@deepseek-ai/dsh-tool-fs`: turn a file's
|
||||
* decoded text into a bounded, line-numbered window (offset/limit, byte cap,
|
||||
* per-line truncation) and format it as the model-facing text block. This is
|
||||
* the `read` tool's RENDERING detail — not a storage primitive, not freshness
|
||||
* policy — so it lives apart from the tool's I/O and event wiring as a pure,
|
||||
* independently-testable module (no cordis, no filesystem).
|
||||
*
|
||||
* The provider (`ctx.fs.readText`/`streamText`) hands back already-decoded text
|
||||
* (UTF-8 validated, binary rejected); {@link buildWindow} only scans that text
|
||||
* for newlines and builds the requested window. A capped line buffer means a
|
||||
* newline-free giant line can never balloon memory even when streamed.
|
||||
* {@link formatReadOutput} turns the resulting {@link FileReadOutcome} into the
|
||||
* `<path>/<content>` envelope the model sees.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/read-render
|
||||
*/
|
||||
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** Maximum characters returned for a single line. */
|
||||
export const READ_MAX_LINE_LENGTH = 2000
|
||||
|
||||
/** Maximum bytes returned for selected file lines. */
|
||||
export const READ_MAX_BYTES = 50 * 1024
|
||||
|
||||
const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`
|
||||
const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1
|
||||
|
||||
/** Resolved read window. The consumer applies its defaults/caps before calling. */
|
||||
export interface ReadWindow {
|
||||
/** 1-based first line to return. */
|
||||
offset: number
|
||||
/** Maximum number of lines to return. */
|
||||
limit: number
|
||||
}
|
||||
|
||||
/** One line returned from a text file. */
|
||||
export interface FileTextLine {
|
||||
/** 1-based line number in the file. */
|
||||
number: number
|
||||
/** Line text without its trailing newline. */
|
||||
text: string
|
||||
}
|
||||
|
||||
/** The windowed result {@link buildWindow} produces from a file's decoded text. */
|
||||
export interface WindowResult {
|
||||
/** Returned lines, already numbered. */
|
||||
lines: FileTextLine[]
|
||||
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
|
||||
totalLines: number
|
||||
/** Whether selected output hit the byte cap before EOF or the requested limit. */
|
||||
truncatedByBytes: boolean
|
||||
}
|
||||
|
||||
/** Outcome of a bounded text read — what {@link formatReadOutput} renders. */
|
||||
export interface FileReadOutcome {
|
||||
/** 1-based first line requested. */
|
||||
offset: number
|
||||
/** Maximum number of lines requested. */
|
||||
limit: number
|
||||
/** Returned lines, already numbered. */
|
||||
lines: FileTextLine[]
|
||||
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
|
||||
totalLines: number
|
||||
/** Whether selected output hit the byte cap before EOF or the requested limit. */
|
||||
truncatedByBytes?: true
|
||||
/** Opaque version of the file at read time. */
|
||||
version: FsVersion
|
||||
}
|
||||
|
||||
interface WindowAccumulator {
|
||||
lines: FileTextLine[]
|
||||
totalLines: number
|
||||
outputBytes: number
|
||||
truncatedByBytes: boolean
|
||||
done: boolean
|
||||
}
|
||||
|
||||
function newAccumulator(): WindowAccumulator {
|
||||
return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false }
|
||||
}
|
||||
|
||||
function truncateLine(line: string): string {
|
||||
return line.length > READ_MAX_LINE_LENGTH ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` : line
|
||||
}
|
||||
|
||||
function lineByteSize(line: string, currentLineCount: number): number {
|
||||
return Buffer.byteLength(line, 'utf8') + (currentLineCount > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void {
|
||||
acc.totalLines += 1
|
||||
if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
|
||||
|
||||
const text = truncateLine(rawLine)
|
||||
const bytes = lineByteSize(text, acc.lines.length)
|
||||
if (acc.outputBytes + bytes > READ_MAX_BYTES) {
|
||||
acc.truncatedByBytes = true
|
||||
acc.done = true
|
||||
return
|
||||
}
|
||||
acc.outputBytes += bytes
|
||||
acc.lines.push({ number: acc.totalLines, text })
|
||||
}
|
||||
|
||||
function stripCarriageReturn(line: string): string {
|
||||
return line.endsWith('\r') ? line.slice(0, -1) : line
|
||||
}
|
||||
|
||||
function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string): WindowResult {
|
||||
if (!acc.truncatedByBytes && request.offset > acc.totalLines && !(acc.totalLines === 0 && request.offset === 1)) {
|
||||
throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, 'FS_NOT_FOUND')
|
||||
}
|
||||
return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bounded, line-numbered window from a file's decoded text chunks.
|
||||
* Accepts an `AsyncIterable<string>` (a chunked `streamText`) or an
|
||||
* `Iterable<string>` (a whole-file `readText` wrapped as `[text]`), so one code
|
||||
* path serves both. Scans for newlines with a capped line buffer (a newline-free
|
||||
* giant line is truncated, never buffered past {@link READ_MAX_LINE_LENGTH}),
|
||||
* enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF.
|
||||
*/
|
||||
export async function buildWindow(
|
||||
chunks: AsyncIterable<string> | Iterable<string>,
|
||||
request: ReadWindow,
|
||||
displayPath: string,
|
||||
): Promise<WindowResult> {
|
||||
const acc = newAccumulator()
|
||||
let lineBuffer = ''
|
||||
|
||||
function appendToLineBuffer(segment: string): void {
|
||||
if (lineBuffer.length >= LINE_BUFFER_CAP) return
|
||||
lineBuffer += segment
|
||||
if (lineBuffer.length > LINE_BUFFER_CAP) lineBuffer = lineBuffer.slice(0, LINE_BUFFER_CAP)
|
||||
}
|
||||
|
||||
function flushLine(): void {
|
||||
consumeLine(acc, stripCarriageReturn(lineBuffer), request)
|
||||
lineBuffer = ''
|
||||
}
|
||||
|
||||
for await (const chunk of chunks) {
|
||||
let startPos = 0
|
||||
let newlinePos: number
|
||||
while ((newlinePos = chunk.indexOf('\n', startPos)) !== -1) {
|
||||
appendToLineBuffer(chunk.slice(startPos, newlinePos))
|
||||
flushLine()
|
||||
startPos = newlinePos + 1
|
||||
if (acc.done) return finish(acc, request, displayPath)
|
||||
}
|
||||
appendToLineBuffer(chunk.slice(startPos))
|
||||
}
|
||||
if (lineBuffer.length > 0) flushLine()
|
||||
return finish(acc, request, displayPath)
|
||||
}
|
||||
|
||||
/** Format a read outcome as one OpenCode-style line-numbered text block body. */
|
||||
export function formatReadOutput(displayPath: string, outcome: FileReadOutcome): string {
|
||||
const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1)
|
||||
let footer: string
|
||||
if (outcome.truncatedByBytes) {
|
||||
footer = `(Output capped. Showing lines ${outcome.offset}-${endLine}. Use offset=${endLine + 1} to continue.)`
|
||||
} else if (endLine < outcome.totalLines) {
|
||||
footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)`
|
||||
} else {
|
||||
footer = `(End of file - total ${outcome.totalLines} lines)`
|
||||
}
|
||||
const body = outcome.lines.length > 0
|
||||
? `${outcome.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}`
|
||||
: footer
|
||||
return `<path>${displayPath}</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
${body}
|
||||
</content>`
|
||||
}
|
||||
120
packages/fs/tool-fs/src/read.ts
Normal file
120
packages/fs/tool-fs/src/read.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* The model-facing `read` tool: inspect a UTF-8 text file and return
|
||||
* line-numbered content with pagination guidance. The tool is the executor — it
|
||||
* stats and reads through `ctx.fs` directly, builds the line window
|
||||
* ({@link module:@deepseek-ai/dsh-tool-fs/read-render}), and emits `fs/observed`
|
||||
* so a policy plugin (`@deepseek-ai/dsh-fs-policy`) can record the read. With
|
||||
* no policy plugin the emit is simply unheard. This module owns the
|
||||
* model-facing schema, argument validation, and the read I/O; the rendering
|
||||
* (windowing + formatting) lives in `read-render.ts` and the
|
||||
* freshness/observation policy is not its concern.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/src/read
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { buildWindow, formatReadOutput } from './read-render.ts'
|
||||
import type { FileReadOutcome } from './read-render.ts'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
|
||||
/** Default and maximum number of lines returned by one `read` call. */
|
||||
export const READ_LIMIT = 2000
|
||||
|
||||
/** Files at or above this size stream; smaller files read whole into memory. */
|
||||
export const STREAM_MIN_SIZE = 10 * 1024 * 1024
|
||||
|
||||
/** Validated `read` arguments after defaulting. */
|
||||
interface ReadInput {
|
||||
filePath: string
|
||||
offset: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: number, name: string): number {
|
||||
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`${name} must be a positive integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }): ReadInput {
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset')
|
||||
const limit = args.limit === undefined ? READ_LIMIT : parsePositiveInteger(args.limit, 'limit')
|
||||
if (limit > READ_LIMIT) throw new Error(`limit must be less than or equal to ${READ_LIMIT}`)
|
||||
return { filePath: args.file_path, offset, limit }
|
||||
}
|
||||
|
||||
/** Register the `read` tool and its system-prompt guidance. */
|
||||
export function applyReadTool(ctx: Context): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:read',
|
||||
order: 100,
|
||||
text: 'Use the read tool to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'read',
|
||||
description: 'Read a UTF-8 text file and return line-numbered content.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: 'Path to read, resolved by the filesystem backend.' },
|
||||
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
|
||||
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${READ_LIMIT}.` },
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseReadArgs(args)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
|
||||
// One stat: type check + size routing + the version recorded as observed.
|
||||
// A writer racing between this stat and the read can at worst make a LATER
|
||||
// guarded edit spuriously FS_STALE_VERSION (fail-closed: re-read; editText
|
||||
// re-checks the version in its lock).
|
||||
const info = await ctx.fs.stat(target, exec.signal)
|
||||
if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
|
||||
// Stream when the file is large OR size is unknown, so a size-less backend
|
||||
// never buffers an arbitrarily large file.
|
||||
const chunks = info.size === undefined || info.size >= STREAM_MIN_SIZE
|
||||
? await ctx.fs.streamText(target, exec.signal)
|
||||
: [await ctx.fs.readText(target, exec.signal)]
|
||||
const window = await buildWindow(chunks, { offset: input.offset, limit: input.limit }, target.displayPath)
|
||||
|
||||
const outcome: FileReadOutcome = {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
lines: window.lines,
|
||||
totalLines: window.totalLines,
|
||||
version: info.version,
|
||||
...window.truncatedByBytes ? { truncatedByBytes: true } : {},
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens). The
|
||||
// read already succeeded; an fs/observed listener is contractually a
|
||||
// synchronous, side-effect-only recorder.
|
||||
ctx.emit('fs/observed', target, info.version, exec)
|
||||
return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }]
|
||||
},
|
||||
// Pure display: a UI card titled by the file, `read` kind (icon), and a
|
||||
// location so an editor can follow along to the file (and the read's offset
|
||||
// line). `rawInput` surfaces offset/limit when the model narrowed the read.
|
||||
presentCall(args) {
|
||||
const detail = [
|
||||
...args.offset !== undefined ? [`offset ${args.offset}`] : [],
|
||||
...args.limit !== undefined ? [`limit ${args.limit}`] : [],
|
||||
].join(', ')
|
||||
return {
|
||||
title: `Read ${args.file_path}`,
|
||||
kind: 'read',
|
||||
locations: [{ path: args.file_path, ...args.offset !== undefined ? { line: args.offset } : {} }],
|
||||
...detail.length > 0 ? { rawInput: detail } : {},
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
24
packages/fs/tool-fs/src/session-cwd.ts
Normal file
24
packages/fs/tool-fs/src/session-cwd.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Derive the working directory a filesystem tool resolves relative paths
|
||||
* against: the calling agent's per-session workspace
|
||||
* (`exec.agent.session.header.cwd`), so each ACP session's `read`/`write`/`edit`
|
||||
* act on ITS workspace, not the server's launch dir — mirroring how
|
||||
* `dsh-tool-bash` defaults a bash `workdir` to the session cwd.
|
||||
*
|
||||
* The `agent` is optional-chained — a non-agent caller yields `undefined`, and
|
||||
* the tool then calls `ctx.fs.resolve(path)` with no base so the backend applies
|
||||
* its own configured default (preserving the non-ACP / no-session behavior).
|
||||
* `session`/`header` are non-optional on a real `Agent`, so only `agent` needs
|
||||
* the guard (mirroring `dsh-tool-bash`'s `resolveWorkdir`). Returning `undefined`
|
||||
* rather than reading `process.cwd()` here keeps the default in ONE place (the
|
||||
* provider), per the "explicit > implicit at seams" convention.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/session-cwd
|
||||
*/
|
||||
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** The session workspace cwd for this call, or `undefined` when none applies. */
|
||||
export function sessionCwd(exec: ToolExecution): string | undefined {
|
||||
return exec.agent?.session.header.cwd
|
||||
}
|
||||
73
packages/fs/tool-fs/src/write.ts
Normal file
73
packages/fs/tool-fs/src/write.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The model-facing `write` tool: create or fully replace a UTF-8 text file. The
|
||||
* tool is the executor: it dispatches the `fs/write-intent` waterfall to
|
||||
* obtain the optional version guard, calls `ctx.fs.writeText` directly, and
|
||||
* emits `fs/observed`. The default thunk returns `undefined` (unconditional
|
||||
* create-or-overwrite — the bare provider); a policy plugin
|
||||
* (`@deepseek-ai/dsh-fs-policy`) occupies the single decision slot and
|
||||
* returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO
|
||||
* times either way.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/src/write
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } {
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
return { filePath: args.file_path, content: args.content }
|
||||
}
|
||||
|
||||
/** Format a write outcome as one model-facing text block body. */
|
||||
export function formatWriteOutput(displayPath: string, outcome: FsWriteOutcome): string {
|
||||
const verb = outcome.operation === 'create' ? 'Created' : 'Updated'
|
||||
return `<path>${displayPath}</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
${verb} file
|
||||
</content>`
|
||||
}
|
||||
|
||||
/** Register the `write` tool and its system-prompt guidance. */
|
||||
export function applyWriteTool(ctx: Context): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:write',
|
||||
order: 101,
|
||||
text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'write',
|
||||
description: 'Create or fully replace a UTF-8 text file.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: 'Path to write, resolved by the filesystem backend.' },
|
||||
content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' },
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseWriteArgs(args)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
// Single-slot decision: the policy plugin produces createIfAbsent/
|
||||
// replaceIfVersion; the bare default is undefined (unconditional). No stat.
|
||||
const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
|
||||
const outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal)
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }]
|
||||
},
|
||||
// Pure display: `edit` kind (an editor treats create/replace as an edit) and
|
||||
// a location so the UI can follow along to the written file. The create-vs-
|
||||
// overwrite fact lives in the model-facing result text; `presentResult` only
|
||||
// sees `{ content, isError }` (not the outcome), so the title stays static.
|
||||
presentCall(args) {
|
||||
return { title: `Write ${args.file_path}`, kind: 'edit', locations: [{ path: args.file_path }] }
|
||||
},
|
||||
}))
|
||||
}
|
||||
84
packages/fs/tool-fs/tests/fs-tools.e2e.ts
Normal file
84
packages/fs/tool-fs/tests/fs-tools.e2e.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { fsHarness, waitForIdle } from './harness.ts'
|
||||
|
||||
/**
|
||||
* With-key smoke for the filesystem tools: a REAL model drives the REAL
|
||||
* read/write/edit tools (over the real local backend + policy gate), and we
|
||||
* verify the WORLD — the file on disk — not the agent's self-report. This is the
|
||||
* "green units, broken product" guard: mocks prove the plumbing, only a real
|
||||
* model proves the tools actually work end-to-end. Key-gated (self-skips without
|
||||
* DEEPSEEK_API_KEY).
|
||||
*/
|
||||
|
||||
let ctx: Context | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
const SYSTEM = 'You are a coding assistant. Use the write tool to create files, the read tool to inspect '
|
||||
+ 'them, and the edit tool for literal replacements. Read a file before editing it. Keep replies terse.'
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => {
|
||||
it('creates, reads, then edits a file — verified on disk', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-'))
|
||||
ctx = await fsHarness(workdir)
|
||||
// agentLoop.create prepares a session with no cwd, so the provider default
|
||||
// (config.cwd = workdir) is the workspace.
|
||||
const agent = ctx.agentLoop.create(AgentId('fs-e2e'), { model: 'deepseek-v4-flash', systemPrompt: SYSTEM })
|
||||
|
||||
agent.send([{ type: 'text', text:
|
||||
'Create a file named note.txt containing exactly the line: status: draft. '
|
||||
+ 'Then read it back, then edit it to replace the literal word draft with final. '
|
||||
+ 'Tell me when done.' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Verify the WORLD: the edit landed on disk.
|
||||
const content = await readFile(join(workdir, 'note.txt'), 'utf8')
|
||||
expect(content).toContain('status: final')
|
||||
expect(content).not.toContain('draft')
|
||||
|
||||
// The log records real read/write/edit tool calls (not bash).
|
||||
const calls = [...agent.session.events].filter(e => e.type === 'tool/call').map(e => e.data.name)
|
||||
expect(calls).toContain('write')
|
||||
expect(calls).toContain('read')
|
||||
expect(calls).toContain('edit')
|
||||
}, 180_000)
|
||||
|
||||
it('resolves a relative path against the per-session cwd (factory meta.cwd)', async () => {
|
||||
// config.cwd is the harness workdir, but the agent's SESSION cwd is a
|
||||
// different dir; the write must land in the SESSION dir, proving the tool
|
||||
// passes the per-session cwd (not the backend default).
|
||||
const configDir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-cfg-'))
|
||||
workdir = configDir
|
||||
const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-session-'))
|
||||
try {
|
||||
ctx = await fsHarness(configDir)
|
||||
const handle = ctx.agents.create({
|
||||
agentId: AgentId('fs-e2e-cwd'),
|
||||
sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`),
|
||||
meta: { cwd: sessionDir },
|
||||
agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM },
|
||||
})
|
||||
handle.agent.send([{ type: 'text', text:
|
||||
'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }])
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
|
||||
// The file is in the SESSION dir, not the config dir.
|
||||
expect(await readFile(join(sessionDir, 'where.txt'), 'utf8')).toContain('here')
|
||||
await expect(readFile(join(configDir, 'where.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
} finally {
|
||||
await rm(sessionDir, { recursive: true, force: true })
|
||||
}
|
||||
}, 180_000)
|
||||
})
|
||||
47
packages/fs/tool-fs/tests/harness.ts
Normal file
47
packages/fs/tool-fs/tests/harness.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
|
||||
/**
|
||||
* Shared harness for the fs-tools with-key e2e: a minimal real agent stack (the
|
||||
* DeepSeek adapter + the real fs provider + the read-before-write/edit policy +
|
||||
* the model-facing read/write/edit tools). Lives outside the *.e2e.ts pattern so
|
||||
* importing it never re-registers another file's tests.
|
||||
*
|
||||
* `fsCwd` is the local backend's default base; a per-session cwd (set via a
|
||||
* session header) overrides it, but this harness creates agents without a
|
||||
* session cwd, so the provider default IS the workspace.
|
||||
*/
|
||||
export async function fsHarness(fsCwd: string): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
|
||||
await ctx.plugin(LocalFileSystem, { cwd: fsCwd })
|
||||
await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs)
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
410
packages/fs/tool-fs/tests/integration.spec.ts
Normal file
410
packages/fs/tool-fs/tests/integration.spec.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Integration tests: the real local backend (`dsh-fs-local`) plus the model
|
||||
* tools (`dsh-tool-fs`) as the executor, exercised through `ctx.tools.execute()`
|
||||
* so nothing bypasses the tool registry. Two deployments:
|
||||
*
|
||||
* - DEFAULT — with the real `dsh-fs-policy` policy gate plugin: read-before-
|
||||
* write/edit, version-guarded mutation, FS_NOT_OBSERVED for unread edits.
|
||||
* - BARE — WITHOUT the policy plugin: every `fs/*` waterfall falls through to
|
||||
* its undefined default, so write/edit are unconditional. This proves the
|
||||
* tool carries no dependency on the policy plugin.
|
||||
*
|
||||
* These verify the WORLD — files are read back from disk and asserted
|
||||
* byte-for-byte — not the tool's self-report.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, readFile, rm, 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 { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
let dir: string
|
||||
let ctx: Context
|
||||
let fiber: Awaited<ReturnType<Context['plugin']>>
|
||||
// A stable session object stands in for an agent session (the file-state
|
||||
// owner). It carries a `header` (no `cwd`) so `sessionCwd(exec)` resolves to
|
||||
// `undefined` and the backend falls back to its configured cwd (= `dir`).
|
||||
const session = { header: {} }
|
||||
|
||||
let callCounter = 0
|
||||
function call(name: string, args: unknown) {
|
||||
return ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
agent: { session } as never,
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await fiber.dispose()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// DEFAULT deployment: the policy gate plugin is loaded.
|
||||
// --------------------------------------------------------------------------
|
||||
describe('default deployment (with dsh-fs-policy)', () => {
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: dir })
|
||||
await ctx.plugin(FsPolicy)
|
||||
fiber = await ctx.plugin(ToolFs)
|
||||
})
|
||||
|
||||
describe('write → disk', () => {
|
||||
it('creates a file with exactly the requested bytes', async () => {
|
||||
const result = await call('write', { file_path: 'new.txt', content: 'line one\nline two\n' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('line one\nline two\n')
|
||||
})
|
||||
|
||||
it('rejects overwriting an existing file without reading it first', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'clobber' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original')
|
||||
})
|
||||
|
||||
it('allows overwriting after a read', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced')
|
||||
})
|
||||
|
||||
it('rejects a full overwrite when the file changed since the read (stale)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('read', () => {
|
||||
it('returns line-numbered content', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'alpha\nbeta')
|
||||
const result = await call('read', { file_path: 'a.txt' })
|
||||
expect(text(result)).toContain('1: alpha')
|
||||
expect(text(result)).toContain('2: beta')
|
||||
expect(text(result)).toContain('(End of file - total 2 lines)')
|
||||
})
|
||||
|
||||
it('reports a binary file as an error', async () => {
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02]))
|
||||
const result = await call('read', { file_path: 'bin' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('paginates a multi-line file with offset/limit', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree\nfour')
|
||||
const result = await call('read', { file_path: 'a.txt', offset: 2, limit: 2 })
|
||||
expect(text(result)).toContain('2: two')
|
||||
expect(text(result)).toContain('3: three')
|
||||
expect(text(result)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit → disk', () => {
|
||||
it('applies a unique literal replacement after a read', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
it('rejects an edit before any read, leaving the file untouched', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
|
||||
})
|
||||
|
||||
it('lets a WINDOWED read authorize an edit when the file is unchanged (freshness, not full-view)', async () => {
|
||||
// A file with more lines than the read window; read only the first line.
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`)
|
||||
await writeFile(join(dir, 'a.txt'), lines.join('\n'))
|
||||
const read = await call('read', { file_path: 'a.txt', offset: 1, limit: 1 })
|
||||
expect(read.isError).toBe(false)
|
||||
expect(text(read)).toContain('(Showing lines 1-1 of 20')
|
||||
|
||||
// Editing a line OUTSIDE the window is authorized because the file is unchanged.
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'line 12', new_string: 'LINE 12' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe(lines.map(l => l === 'line 12' ? 'LINE 12' : l).join('\n'))
|
||||
})
|
||||
|
||||
it('rejects an edit when the file changed since the windowed read (stale before matching)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
await call('read', { file_path: 'a.txt', offset: 1, limit: 1 })
|
||||
await writeFile(join(dir, 'a.txt'), 'goodbye') // out-of-band change removes 'world'
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('rejects an ambiguous match without replace_all', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a a a')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a')
|
||||
})
|
||||
|
||||
it('replaces all matches with replace_all', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a a a')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
|
||||
})
|
||||
|
||||
it('supports a full write→edit cycle without an intervening read', async () => {
|
||||
await call('write', { file_path: 'a.txt', content: 'one two' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'two', new_string: 'three' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('one three')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the gate records only through the events (no method coupling)', () => {
|
||||
it('a direct ctx.fs.readText records no observed-state, so a later edit rejects', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
// Reach AROUND the tool — an explicit escape hatch for non-tool consumers.
|
||||
await ctx.fs.readText(await ctx.fs.resolve('a.txt'))
|
||||
// The model-facing edit still rejects: the read did not emit fs/observed.
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('stat budget', () => {
|
||||
it('read stats once; write and edit never stat in the tool (the gate stats zero too)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const statSpy = vi.spyOn(ctx.fs, 'stat')
|
||||
|
||||
// read: exactly one stat (type + size routing + observed version).
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
expect(statSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// edit (guarded, after the read): the gate supplies vObserved; the tool
|
||||
// does not stat to manufacture a basis. CAS happens in editText's lock.
|
||||
statSpy.mockClear()
|
||||
const edited = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(edited.isError).toBe(false)
|
||||
expect(statSpy).not.toHaveBeenCalled()
|
||||
|
||||
// write (guarded replace, after the edit refreshed observed state): zero stat.
|
||||
statSpy.mockClear()
|
||||
const written = await call('write', { file_path: 'a.txt', content: 'fresh' })
|
||||
expect(written.isError).toBe(false)
|
||||
expect(statSpy).not.toHaveBeenCalled()
|
||||
statSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// BARE deployment: the tool suite WITHOUT the policy gate.
|
||||
// --------------------------------------------------------------------------
|
||||
describe('bare provider (no dsh-fs-policy)', () => {
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-bare-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: dir })
|
||||
fiber = await ctx.plugin(ToolFs)
|
||||
})
|
||||
|
||||
it('read works (it never needed policy)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'alpha\nbeta')
|
||||
const result = await call('read', { file_path: 'a.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('1: alpha')
|
||||
})
|
||||
|
||||
it('write unconditionally creates a new file', async () => {
|
||||
const result = await call('write', { file_path: 'new.txt', content: 'fresh' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
|
||||
})
|
||||
|
||||
it('write unconditionally OVERWRITES an existing unread file', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'clobbered' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered')
|
||||
})
|
||||
|
||||
it('edit unconditionally edits an UNREAD existing file', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
it('edit of a MISSING target reports FS_STALE_VERSION even on the unguarded path', async () => {
|
||||
const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'absent', new_string: 'x' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('neither write nor edit stats in the tool on the bare path', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const statSpy = vi.spyOn(ctx.fs, 'stat')
|
||||
expect((await call('write', { file_path: 'a.txt', content: 'x y' })).isError).toBe(false)
|
||||
expect((await call('edit', { file_path: 'a.txt', old_string: 'y', new_string: 'z' })).isError).toBe(false)
|
||||
expect(statSpy).not.toHaveBeenCalled()
|
||||
statSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Per-session cwd: a relative file_path resolves against the CALLING session's
|
||||
// workspace (`exec.agent.session.header.cwd`), NOT the backend's config.cwd —
|
||||
// so an ACP editor's per-session dir wins, matching dsh-tool-bash. The regression
|
||||
// this guards: before the seam fix the tool passed no cwd, so a relative write
|
||||
// landed in config.cwd instead of the session dir.
|
||||
// --------------------------------------------------------------------------
|
||||
describe('per-session cwd', () => {
|
||||
let sessionDir: string
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-cfg-'))
|
||||
sessionDir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-session-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: dir }) // config.cwd = dir, NOT sessionDir
|
||||
await ctx.plugin(FsPolicy)
|
||||
fiber = await ctx.plugin(ToolFs)
|
||||
})
|
||||
afterEach(async () => { await rm(sessionDir, { recursive: true, force: true }) })
|
||||
|
||||
const callIn = (sessionObj: object, name: string, args: unknown) =>
|
||||
ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
agent: { session: sessionObj } as never,
|
||||
})
|
||||
|
||||
it('writes a relative path into the SESSION cwd, not config.cwd', async () => {
|
||||
const result = await callIn({ header: { cwd: sessionDir } }, 'write', { file_path: 'note.txt', content: 'hi' })
|
||||
expect(result.isError).toBe(false)
|
||||
// Verify the WORLD: the file is in the session dir, and NOT in config.cwd.
|
||||
expect(await readFile(join(sessionDir, 'note.txt'), 'utf8')).toBe('hi')
|
||||
await expect(readFile(join(dir, 'note.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('read + edit both resolve against the session cwd (end-to-end)', async () => {
|
||||
// ONE session object across both calls — observed-state keys by owner
|
||||
// identity, so read must record under the same owner the edit reads.
|
||||
const session = { header: { cwd: sessionDir } }
|
||||
await writeFile(join(sessionDir, 'code.txt'), 'alpha')
|
||||
expect((await callIn(session, 'read', { file_path: 'code.txt' })).isError).toBe(false)
|
||||
const edited = await callIn(session, 'edit', { file_path: 'code.txt', old_string: 'alpha', new_string: 'beta' })
|
||||
expect(edited.isError).toBe(false)
|
||||
expect(await readFile(join(sessionDir, 'code.txt'), 'utf8')).toBe('beta')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Abort-through-the-tool, tool-tier concurrency, and the fs/observed contract —
|
||||
// all through ctx.tools.execute() against the REAL backend + policy.
|
||||
// --------------------------------------------------------------------------
|
||||
describe('signal, concurrency, and the fs/observed contract', () => {
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: dir })
|
||||
await ctx.plugin(FsPolicy)
|
||||
fiber = await ctx.plugin(ToolFs)
|
||||
})
|
||||
|
||||
const session = { header: {} }
|
||||
const callSig = (signal: AbortSignal, name: string, args: unknown) =>
|
||||
ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never, signal })
|
||||
const callOwned = (name: string, args: unknown) =>
|
||||
ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never })
|
||||
|
||||
it('a pre-aborted signal makes read/write/edit return isError FS_ABORTED', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello')
|
||||
const read = await callSig(AbortSignal.abort(), 'read', { file_path: 'a.txt' })
|
||||
expect(read.isError).toBe(true)
|
||||
expect(read.error).toMatchObject({ code: 'FS_ABORTED' })
|
||||
|
||||
const write = await callSig(AbortSignal.abort(), 'write', { file_path: 'new.txt', content: 'x' })
|
||||
expect(write.isError).toBe(true)
|
||||
expect(write.error).toMatchObject({ code: 'FS_ABORTED' })
|
||||
await expect(readFile(join(dir, 'new.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
|
||||
// Read first (un-aborted, SAME session owner) so the edit clears the
|
||||
// observation gate; then the aborted edit fails on the signal, not on
|
||||
// FS_NOT_OBSERVED.
|
||||
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||
const edit = await callSig(AbortSignal.abort(), 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' })
|
||||
expect(edit.isError).toBe(true)
|
||||
expect(edit.error).toMatchObject({ code: 'FS_ABORTED' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello') // unchanged
|
||||
})
|
||||
|
||||
it('two concurrent edits of the same file, same session: one wins, one FS_STALE_VERSION', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'base value here')
|
||||
// One read establishes the observed version both edits guard against; then
|
||||
// race two edits so both carry the SAME observed version (the barrier).
|
||||
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||
const [one, two] = await Promise.all([
|
||||
callOwned('edit', { file_path: 'a.txt', old_string: 'base', new_string: 'ONE', replaceAll: false }),
|
||||
callOwned('edit', { file_path: 'a.txt', old_string: 'value', new_string: 'TWO', replaceAll: false }),
|
||||
])
|
||||
const errors = [one, two].filter(r => r.isError)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]?.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
// The world is consistent: exactly one edit landed.
|
||||
const onDisk = await readFile(join(dir, 'a.txt'), 'utf8')
|
||||
expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true)
|
||||
})
|
||||
|
||||
it('a throwing fs/observed listener surfaces as isError, but the mutation already hit disk', async () => {
|
||||
// fs/observed is a plain ctx.emit AFTER the write succeeded; a throwing
|
||||
// listener cannot roll the write back — it only turns the tool result into
|
||||
// isError. The file must still carry the written bytes.
|
||||
ctx.on('fs/observed', () => { throw new Error('recording bug') })
|
||||
const result = await callOwned('write', { file_path: 'w.txt', content: 'durable' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(await readFile(join(dir, 'w.txt'), 'utf8')).toBe('durable')
|
||||
})
|
||||
})
|
||||
102
packages/fs/tool-fs/tests/read-render.spec.ts
Normal file
102
packages/fs/tool-fs/tests/read-render.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Cordis-free tests for the line-windowing module: offset/limit windows, byte
|
||||
* caps, per-line truncation, CRLF stripping, offset-past-EOF rejection, and the
|
||||
* capped line buffer for newline-free giant lines — all over an async-iterable
|
||||
* of decoded text chunks (so one code path serves whole-file and streamed reads).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWindow, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-tool-fs'
|
||||
import type { ReadWindow } from '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
const READ_ALL: ReadWindow = { offset: 1, limit: 2000 }
|
||||
|
||||
/** Yield `text` as one chunk (whole-file read shape). */
|
||||
async function* whole(text: string): AsyncIterable<string> {
|
||||
yield text
|
||||
}
|
||||
|
||||
/** Yield `text` split into fixed-size chunks (streamed read shape). */
|
||||
async function* chunked(text: string, size: number): AsyncIterable<string> {
|
||||
for (let i = 0; i < text.length; i += size) yield text.slice(i, i + size)
|
||||
}
|
||||
|
||||
describe('buildWindow', () => {
|
||||
it('numbers lines and reports total for a whole-file read', async () => {
|
||||
const result = await buildWindow(whole('one\ntwo\nthree'), READ_ALL, 'f')
|
||||
expect(result.lines).toEqual([
|
||||
{ number: 1, text: 'one' },
|
||||
{ number: 2, text: 'two' },
|
||||
{ number: 3, text: 'three' },
|
||||
])
|
||||
expect(result.totalLines).toBe(3)
|
||||
expect(result.truncatedByBytes).toBe(false)
|
||||
})
|
||||
|
||||
it('applies offset/limit', async () => {
|
||||
const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2 }, 'f')
|
||||
expect(result.lines.map(l => l.number)).toEqual([2, 3])
|
||||
expect(result.totalLines).toBe(4)
|
||||
})
|
||||
|
||||
it('strips CRLF', async () => {
|
||||
const result = await buildWindow(whole('one\r\ntwo\r\n'), READ_ALL, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('truncates an over-long line', async () => {
|
||||
const result = await buildWindow(whole('x'.repeat(3000)), READ_ALL, 'f')
|
||||
expect(result.lines[0]?.text).toContain(`... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`)
|
||||
})
|
||||
|
||||
it('caps output bytes and reports truncatedByBytes', async () => {
|
||||
const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
|
||||
const result = await buildWindow(whole(big), READ_ALL, 'f')
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
})
|
||||
|
||||
it('reads an empty file at offset 1 as zero lines', async () => {
|
||||
const result = await buildWindow(whole(''), READ_ALL, 'f')
|
||||
expect(result.lines).toEqual([])
|
||||
expect(result.totalLines).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects an offset past EOF', async () => {
|
||||
await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1 }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('flushes a final line with no trailing newline', async () => {
|
||||
const result = await buildWindow(whole('one\ntwo'), READ_ALL, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('handles a trailing newline (no dangling empty line)', async () => {
|
||||
const result = await buildWindow(whole('one\ntwo\n'), READ_ALL, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
expect(result.totalLines).toBe(2)
|
||||
})
|
||||
|
||||
describe('chunked input (streamed read shape)', () => {
|
||||
it('windows identically when text arrives in small chunks', async () => {
|
||||
const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1 }, 'f')
|
||||
expect(result.lines).toEqual([{ number: 2, text: 'two' }])
|
||||
expect(result.totalLines).toBe(3)
|
||||
})
|
||||
|
||||
it('caps a newline-free giant line split across chunks without unbounded buffering', async () => {
|
||||
const result = await buildWindow(chunked('z'.repeat(5000), 256), READ_ALL, 'f')
|
||||
expect(result.lines[0]?.text).toContain(`... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`)
|
||||
})
|
||||
|
||||
it('caps output bytes mid-stream', async () => {
|
||||
const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
|
||||
const result = await buildWindow(chunked(big, 512), READ_ALL, 'f')
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
})
|
||||
|
||||
it('flushes a final newline-terminated line across a chunk boundary', async () => {
|
||||
const result = await buildWindow(chunked('one\ntwo\n', 3), READ_ALL, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
})
|
||||
})
|
||||
385
packages/fs/tool-fs/tests/tools.spec.ts
Normal file
385
packages/fs/tool-fs/tests/tools.spec.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Consumer-surface tests for the filesystem tools as the EXECUTOR. They run the
|
||||
* REAL `@deepseek-ai/dsh-fs-policy` gate plugin (the genuine policy
|
||||
* collaborator, per the prefer-the-real-implementation rule) over a fake
|
||||
* `ctx.fs` provider, so they verify schemas, argument validation, result
|
||||
* formatting, FsError→isError propagation, and that each tool dispatches the
|
||||
* `fs/*` waterfalls + records observed-state through the gate (read authorizes a
|
||||
* later edit) — not just that it moved bytes.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } 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 { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import { formatReadOutput, STREAM_MIN_SIZE } from '@deepseek-ai/dsh-tool-fs'
|
||||
import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
/** An in-memory fake provider; a test can arm a rejection on any primitive. */
|
||||
class FakeFs extends FileSystem {
|
||||
files = new Map<string, string>()
|
||||
rejectWith?: FsError
|
||||
writeIntents: (FsWriteIntent | undefined)[] = []
|
||||
editIntents: ({ version: FsVersion } | undefined)[] = []
|
||||
|
||||
private throwIfArmed(): void {
|
||||
if (this.rejectWith) throw this.rejectWith
|
||||
}
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
return { inputPath: path, targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` }
|
||||
}
|
||||
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
|
||||
this.throwIfArmed()
|
||||
const content = this.files.get(target.targetKey)
|
||||
if (content === undefined) return undefined
|
||||
return { version: FsVersion('v1'), type: 'file', size: content.length }
|
||||
}
|
||||
override async readText(target: FsTarget): Promise<string> {
|
||||
return this.files.get(target.targetKey) ?? ''
|
||||
}
|
||||
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
return (async function* () { yield content })()
|
||||
}
|
||||
override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise<FsWriteOutcome> {
|
||||
this.throwIfArmed()
|
||||
this.writeIntents.push(expected)
|
||||
const existed = this.files.has(target.targetKey)
|
||||
this.files.set(target.targetKey, content)
|
||||
return { operation: existed ? 'update' : 'create', version: FsVersion('v2') }
|
||||
}
|
||||
override async editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }): Promise<FsEditOutcome> {
|
||||
this.throwIfArmed()
|
||||
this.editIntents.push(expected)
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString))
|
||||
return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') }
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeFs)
|
||||
await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs)
|
||||
const fs = ctx.fs as FakeFs
|
||||
return { ctx, fs }
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
function call(ctx: Context, name: string, args: unknown, agent?: object) {
|
||||
return ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...agent ? { agent: agent 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('registration', () => {
|
||||
it('registers read, write, and edit', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write'])
|
||||
})
|
||||
|
||||
it('registers prompt sections for each tool', async () => {
|
||||
const { ctx } = await setup()
|
||||
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
|
||||
expect(prompt).toContain('Use the read tool')
|
||||
expect(prompt).toContain('Use the write tool')
|
||||
expect(prompt).toContain('Use the edit tool')
|
||||
})
|
||||
|
||||
it('stays pending until ctx.fs exists (inject)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(ToolFs) // no fs provider
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('unregisters everything on fiber disposal (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeFs)
|
||||
await ctx.plugin(FsPolicy)
|
||||
const fiber = await ctx.plugin(ToolFs)
|
||||
// Each tool contributes BOTH a schema and a prompt section; disposal must
|
||||
// withdraw both, not just the schemas.
|
||||
expect(ctx.tools.schemas()).toHaveLength(3)
|
||||
const sectionNames = (a: { sections: { name: string }[] }) => a.sections.map(s => s.name).sort()
|
||||
expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['tool:edit', 'tool:read', 'tool:write'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('read tool', () => {
|
||||
it('formats line-numbered content with a footer', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:a.txt', 'hello\nworld')
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(`<path>/abs/a.txt</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
1: hello
|
||||
2: world
|
||||
|
||||
(End of file - total 2 lines)
|
||||
</content>`)
|
||||
})
|
||||
|
||||
it('rejects a non-positive offset via arg validation', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt', offset: 0 })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('offset must be a positive integer')
|
||||
})
|
||||
|
||||
it('rejects a fractional or NaN offset, and a zero/negative limit', async () => {
|
||||
const { ctx } = await setup()
|
||||
for (const args of [
|
||||
{ file_path: 'a.txt', offset: 1.5 },
|
||||
{ file_path: 'a.txt', offset: Number.NaN },
|
||||
{ file_path: 'a.txt', limit: 0 },
|
||||
{ file_path: 'a.txt', limit: -3 },
|
||||
]) {
|
||||
const result = await call(ctx, 'read', args)
|
||||
expect(result.isError, JSON.stringify(args)).toBe(true)
|
||||
expect(text(result)).toMatch(/must be a positive integer/)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a limit above the cap', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt', limit: 99999 })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('less than or equal to 2000')
|
||||
})
|
||||
|
||||
it('rejects a blank file_path', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: ' ' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('file_path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('records observed state so a follow-up edit by the same session is authorized', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = { header: {} }
|
||||
fs.files.set('key:a.txt', 'hello')
|
||||
expect((await call(ctx, 'read', { file_path: 'a.txt' }, { session })).isError).toBe(false)
|
||||
const edited = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }, { session })
|
||||
expect(edited.isError).toBe(false)
|
||||
expect(fs.editIntents).toEqual([{ version: 'v1' }])
|
||||
})
|
||||
|
||||
it('propagates FS_NOT_FOUND for an absent file', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'missing.txt' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('rejects a non-regular target', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:d', '')
|
||||
fs.stat = async () => ({ version: FsVersion('v1'), type: 'directory' })
|
||||
const result = await call(ctx, 'read', { file_path: 'd' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('streams a large file (size at/above the cap) instead of reading whole', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:big.txt', 'alpha\nbeta')
|
||||
const readSpy = vi.spyOn(fs, 'readText')
|
||||
const streamSpy = vi.spyOn(fs, 'streamText')
|
||||
fs.stat = async () => ({ version: FsVersion('v1'), type: 'file', size: STREAM_MIN_SIZE })
|
||||
const result = await call(ctx, 'read', { file_path: 'big.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('1: alpha')
|
||||
expect(streamSpy).toHaveBeenCalled()
|
||||
expect(readSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('streams when the backend reports no size (never buffers a size-less file)', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:a.txt', 'alpha')
|
||||
const streamSpy = vi.spyOn(fs, 'streamText')
|
||||
fs.stat = async () => ({ version: FsVersion('v1'), type: 'file' }) // no size
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(streamSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a byte-capped read as a truncated footer', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
// Many long lines so the window hits the byte cap before EOF.
|
||||
fs.files.set('key:big.txt', Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n'))
|
||||
const result = await call(ctx, 'read', { file_path: 'big.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('Output capped.')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('formatReadOutput footer variants', () => {
|
||||
const base: FileReadOutcome = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: FsVersion('v') }
|
||||
|
||||
it('reports a byte-capped read', () => {
|
||||
const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true })
|
||||
expect(out).toContain('(Output capped. Showing lines 1-1. Use offset=2 to continue.)')
|
||||
})
|
||||
|
||||
it('reports a more-remaining page', () => {
|
||||
const out = formatReadOutput('/f', { ...base, totalLines: 99 })
|
||||
expect(out).toContain('(Showing lines 1-1 of 99. Use offset=2 to continue.)')
|
||||
})
|
||||
|
||||
it('reports end-of-file', () => {
|
||||
expect(formatReadOutput('/f', base)).toContain('(End of file - total 1 lines)')
|
||||
})
|
||||
|
||||
it('renders an empty file as just the footer', () => {
|
||||
const out = formatReadOutput('/f', { ...base, lines: [], totalLines: 0 })
|
||||
expect(out).toContain('(End of file - total 0 lines)')
|
||||
expect(out).not.toContain(': ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('write tool', () => {
|
||||
it('formats a create result and uses createIfAbsent (unobserved, with the gate)', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: { header: {} } })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('Created file')
|
||||
expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }])
|
||||
})
|
||||
|
||||
it('rejects a blank file_path', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'write', { file_path: ' ', content: 'hi' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('file_path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('propagates a backend FsError as an isError result carrying its code', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION')
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'FsError', code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit tool', () => {
|
||||
it('formats a single-replacement success after a read', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = { header: {} }
|
||||
fs.files.set('key:a.txt', 'a')
|
||||
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session })
|
||||
expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.')
|
||||
})
|
||||
|
||||
it('formats the replace_all success message distinctly', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = { header: {} }
|
||||
fs.files.set('key:a.txt', 'a a a')
|
||||
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true }, { session })
|
||||
expect(text(result)).toBe('The file /abs/a.txt has been updated. All occurrences were successfully replaced.')
|
||||
})
|
||||
|
||||
it('rejects identical old/new strings', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'x' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('must differ')
|
||||
})
|
||||
|
||||
it('rejects an empty old_string', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: '', new_string: 'x' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('old_string must be a non-empty string')
|
||||
})
|
||||
|
||||
it('rejects a blank file_path', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'edit', { file_path: ' ', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('file_path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('propagates FS_NOT_OBSERVED when the file was never read (the gate decides)', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:a.txt', 'hello')
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: { header: {} } })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-owned presentation (pure presentCall)', () => {
|
||||
// presentCall is a pure display function of args (no I/O); it drives the ACP
|
||||
// card's title/kind and the `locations` an editor follows along to.
|
||||
const presentCall = async (name: string, args: unknown) => {
|
||||
const { ctx } = await setup()
|
||||
return ctx.tools.get(name)?.presentCall?.(args)
|
||||
}
|
||||
|
||||
it('read: titles by file, read kind, location with the offset line', async () => {
|
||||
expect(await presentCall('read', { file_path: 'src/a.ts', offset: 12, limit: 40 })).toEqual({
|
||||
title: 'Read src/a.ts', kind: 'read', rawInput: 'offset 12, limit 40',
|
||||
locations: [{ path: 'src/a.ts', line: 12 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('read: omits rawInput and the location line when offset/limit are unset', async () => {
|
||||
expect(await presentCall('read', { file_path: 'a.txt' })).toEqual({
|
||||
title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('write: titles by file, edit kind, location', async () => {
|
||||
expect(await presentCall('write', { file_path: 'out.txt', content: 'x' })).toEqual({
|
||||
title: 'Write out.txt', kind: 'edit', locations: [{ path: 'out.txt' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('edit: titles by file, edit kind, an old→new rawInput summary, location', async () => {
|
||||
expect(await presentCall('edit', { file_path: 'a.txt', old_string: 'foo', new_string: 'bar' })).toEqual({
|
||||
title: 'Edit a.txt', kind: 'edit', rawInput: '"foo" → "bar"', locations: [{ path: 'a.txt' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('edit: clips a long old/new string in the rawInput summary', async () => {
|
||||
const long = 'a'.repeat(60)
|
||||
const p = await presentCall('edit', { file_path: 'a.txt', old_string: long, new_string: 'b' })
|
||||
expect((p as { rawInput: string }).rawInput).toBe(`${JSON.stringify(`${'a'.repeat(40)}…`)} → ${JSON.stringify('b')}`)
|
||||
})
|
||||
})
|
||||
17
packages/fs/tool-fs/tsconfig.json
Normal file
17
packages/fs/tool-fs/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
{ "path": "../fs" },
|
||||
{ "path": "../fs-policy" }
|
||||
]
|
||||
}
|
||||
@@ -162,9 +162,6 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
trace.surface.push(event.seq)
|
||||
} else {
|
||||
const { start, end } = se.surfaceOp
|
||||
if (start > end) {
|
||||
throw new InvariantError(`surface replace: start ${start} must be <= end ${end}`)
|
||||
}
|
||||
const startIdx = trace.surface.indexOf(start)
|
||||
if (startIdx === -1) {
|
||||
throw new InvariantError(`surface replace: start seq ${start} is not on the surface`)
|
||||
|
||||
@@ -530,16 +530,17 @@ describe('surface invariants', () => {
|
||||
}).toThrow(/unknown seq 2/)
|
||||
})
|
||||
|
||||
it('rejects replace op with start > end', async () => {
|
||||
it('rejects a replace whose start is positioned after its end on the surface', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
// start > end is invalid (reversed order).
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
|
||||
// Reversed range: start seq 3 is at a later surface position than end seq 2.
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2] })
|
||||
}).toThrow(/must be <= end/)
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 2 }, sourceEventSeqs: [2, 3] })
|
||||
}).toThrow(/is after end seq 2 .* on the surface/)
|
||||
})
|
||||
|
||||
it('rejects a replace whose sourceEventSeqs omits a shadowed surface node', async () => {
|
||||
@@ -608,6 +609,23 @@ describe('surface invariants', () => {
|
||||
}).toThrow(/is after end seq 4 .* on the surface/)
|
||||
})
|
||||
|
||||
it('accepts a replace whose start seq exceeds its end seq when the surface position order is valid', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
|
||||
// Replace node 2 (position 0) with seq 4 — surface becomes [4, 3], so the
|
||||
// head seq (4) is numerically GREATER than the tail seq (3): the surface is
|
||||
// not seq-ordered. A replace spanning start=4 (pos 0) … end=3 (pos 1) is
|
||||
// valid positionally and must be accepted even though start seq > end seq.
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 4, end: 3 }, sourceEventSeqs: [4, 3] }) // seq 5
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a replace that omits sourceEventSeqs entirely', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
|
||||
@@ -28,7 +28,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
|
||||
| `session/load` | `ctx.agents.resume(...)` | replays the persisted event log to the client as `session/update` — the USER side (`user/message` → `user_message_chunk`), assistant text/reasoning (`assistant/chunk`), and tool calls/results (`tool/call` + `tool/result`). Re-loading an already-live id is rejected; the id's load slot is reserved (`loadingIds`) BEFORE the async resume so a pipelined load of the SAME id can't leak a second agent (distinct ids load concurrently). The resumed session keeps its PERSISTED header `cwd`, so its bash tools run in the original workspace; the requested `cwd` must be absolute and match the persisted `cwd`. After the async resume a `closed` re-check refuses to install a record if the bridge tore down mid-load |
|
||||
| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
|
||||
| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) |
|
||||
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (title/kind/rawInput/content owned by the TOOL via `presentCall`/`presentResult` — see Tool-call presentation) |
|
||||
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (title/kind/rawInput/content/locations owned by the TOOL via `presentCall`/`presentResult` — see Tool-call presentation) |
|
||||
|
||||
## Multi-session
|
||||
|
||||
@@ -42,7 +42,7 @@ Each session runs in its own workspace, recorded as the session's `SessionHeader
|
||||
|
||||
## Tool-call presentation
|
||||
|
||||
How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state: a human-readable `title`, a `kind` for the icon, the salient `rawInput` to show in a detail view, and optional `content` blocks shown alongside) and `presentResult(args, result)` (completed state: an optional replacement `title` and reformatted `content`) on its `dsh-tools` definition. The bridge looks the definition up by name in `ctx.tools` and maps the neutral `ToolCallPresentation`/`ToolResultPresentation` to the ACP `tool_call`/`tool_call_update` wire shapes. A tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind inferred from the name). For example `dsh-tool-bash` sets the title to the exact `command` ("ls -la src"), `kind: 'execute'`, the `command` as `rawInput`, the model `description` as a `content` text block, and wraps the completed output in a fenced ` ```console ` block. (The command is the title because an editor hides `rawInput` for execute-kind cards — Zed renders it only for non-terminal tools — and the reference adapters likewise use the command as an execute tool's title.)
|
||||
How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state: a human-readable `title`, a `kind` for the icon, the salient `rawInput` to show in a detail view, optional `content` blocks shown alongside, and optional `locations` — `{ path, line? }[]` files the call reads/modifies, forwarded as `tool_call.locations` so an editor can follow along) and `presentResult(args, result)` (completed state: an optional replacement `title` and reformatted `content`) on its `dsh-tools` definition. The bridge looks the definition up by name in `ctx.tools` and maps the neutral `ToolCallPresentation`/`ToolResultPresentation` to the ACP `tool_call`/`tool_call_update` wire shapes. A tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind inferred from the name). For example `dsh-tool-bash` sets the title to the exact `command` ("ls -la src"), `kind: 'execute'`, the `command` as `rawInput`, the model `description` as a `content` text block, and wraps the completed output in a fenced ` ```console ` block; the `dsh-tool-fs` `read`/`write`/`edit` tools set a `Read/Write/Edit <path>` title, a `read`/`edit` kind, and a `locations` entry for the file. (The command is the title because an editor hides `rawInput` for execute-kind cards — Zed renders it only for non-terminal tools — and the reference adapters likewise use the command as an execute tool's title.)
|
||||
|
||||
The `tool/result` session event carries only `{ callId, content, isError }` — not the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones.
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
|
||||
| `content` blocks | S | ✅ | ✅ | ✅ | Text content; the description renders above the card. |
|
||||
| `diff` content | S | ❌ | ✅ | ✅ | No structured diff rendering for edits (would need a diffing edit tool + presenter). |
|
||||
| `terminal` content | S | ✅ | ✅ | ✅ | Via the Zed `_meta` terminal convention (see below), not the spec `terminal/*` sub-protocol. |
|
||||
| `locations` (follow-along) | S | ❌ | ✅ | ✅ | No file-location hints emitted. |
|
||||
| `locations` (follow-along) | S | ✅ | ✅ | ✅ | The `read`/`write`/`edit` tools emit `{ path, line? }` file-location hints via `presentCall`. |
|
||||
| `rawInput` | S | ✅ | ⚠️ | ✅ | Parsed tool args surfaced as `rawInput`. |
|
||||
| `rawOutput` | S | ❌ | ⚠️ | ✅ | Not emitted. |
|
||||
|
||||
@@ -147,7 +147,7 @@ Ranked by how commonly the reference adapters ship them and how much UX they unl
|
||||
5. **Slash commands** (`available_commands_update`).
|
||||
6. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
|
||||
7. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
|
||||
8. **Diff + location tool rendering** — `diff` content and `locations` for edit tools.
|
||||
8. **Diff tool rendering** — structured `diff` content for edit tools (the `locations` follow-along hint already ships on `read`/`write`/`edit`).
|
||||
9. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
|
||||
10. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
|
||||
|
||||
|
||||
@@ -38,12 +38,15 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
|
||||
@@ -836,6 +836,7 @@ export function streamSessionEventUpdate(
|
||||
kind: present.kind,
|
||||
status: 'in_progress',
|
||||
...present.rawInput !== undefined ? { rawInput: present.rawInput } : {},
|
||||
...present.locations !== undefined ? { locations: present.locations } : {},
|
||||
...callContent.length > 0 ? { content: callContent } : {},
|
||||
...asTerminal
|
||||
? { _meta: { terminal_info: { terminal_id: event.data.callId, cwd: terminalCwd(present.terminal, terminal.cwd) } } }
|
||||
@@ -926,6 +927,8 @@ interface ResolvedCallPresentation {
|
||||
rawInput?: unknown
|
||||
/** UI content shown on the pending call (e.g. a bash description text block above the card). */
|
||||
content?: ContentBlock[]
|
||||
/** Files this call reads/modifies (mapped to ACP `tool_call.locations`), for editor follow-along. */
|
||||
locations?: { path: string; line?: number }[]
|
||||
/** Tool's request to render as a terminal (the pending side carries the cwd). */
|
||||
terminal?: ToolTerminal
|
||||
}
|
||||
@@ -1005,6 +1008,7 @@ export class ToolPresenter {
|
||||
kind: present.kind ?? 'other',
|
||||
rawInput: present.rawInput,
|
||||
...present.content !== undefined ? { content: present.content } : {},
|
||||
...present.locations !== undefined ? { locations: present.locations } : {},
|
||||
...present.terminal !== undefined ? { terminal: present.terminal } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
@@ -165,6 +168,15 @@ export async function makeBridgeHarness(options: {
|
||||
* tool + the bridge's own todo/write→plan mapping, not a stand-in.
|
||||
*/
|
||||
withTodo?: boolean
|
||||
/**
|
||||
* Plug the REAL filesystem stack (`dsh-fs-local` + `dsh-fs-policy` +
|
||||
* `dsh-tool-fs`) so a test can drive `read`/`write`/`edit` through the bridge
|
||||
* and assert their tool-owned presentation (title/kind/`locations`) on the
|
||||
* wire — the shipping tools, not a stand-in. `fsCwd` sets the local backend's
|
||||
* base directory (default: `storageDir`).
|
||||
*/
|
||||
withFs?: boolean
|
||||
fsCwd?: string
|
||||
} = { storageDir: '' }): Promise<BridgeHarness> {
|
||||
const adapter = new MockAdapter(options.script ?? [])
|
||||
|
||||
@@ -183,6 +195,11 @@ export async function makeBridgeHarness(options: {
|
||||
if (options.withTodo) {
|
||||
await ctx.plugin(ToolTodo)
|
||||
}
|
||||
if (options.withFs) {
|
||||
await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir })
|
||||
await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs)
|
||||
}
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
// Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionNotification } from '@agentclientprotocol/sdk'
|
||||
import type { ToolDefinition, ToolRegistry } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolDefinition, ToolRegistry as ToolRegistryType } from '@deepseek-ai/dsh-tools'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import FsLocal from '@deepseek-ai/dsh-fs-local'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import { streamSessionEventUpdate, agentOptions, todosToPlan, ToolPresenter } from '../src/index.ts'
|
||||
|
||||
/** Collect the updates a single event produces (no presenter → generic fallback). */
|
||||
@@ -20,7 +25,7 @@ function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] {
|
||||
}
|
||||
|
||||
/** A tiny tool registry stub exposing just `get` for {@link ToolPresenter}. */
|
||||
function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistry, 'get'> {
|
||||
function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistryType, 'get'> {
|
||||
const map = new Map(tools.map(t => [t.name, t]))
|
||||
return { get: name => map.get(name) }
|
||||
}
|
||||
@@ -330,6 +335,38 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
|
||||
expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom' })
|
||||
expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] })
|
||||
})
|
||||
|
||||
it('forwards a tool-owned `locations` onto the wire tool_call (REAL fs read/edit tools)', async () => {
|
||||
// Use the SHIPPING fs tools (not a stand-in), booted through their real
|
||||
// plugins, so the wire tool_call carries the actual presentCall output —
|
||||
// including `locations` for editor follow-along. (AGENTS.md "prefer the real
|
||||
// implementation over a mock".)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FsLocal)
|
||||
await ctx.plugin(ToolFs)
|
||||
const presenter = new ToolPresenter(ctx.tools)
|
||||
|
||||
const [readCall] = updatesWith(presenter, evt('tool/call', {
|
||||
turn: 1, step: 1, callId: CallId('r1'), name: 'read',
|
||||
arguments: JSON.stringify({ file_path: 'src/a.ts', offset: 12 }),
|
||||
}))
|
||||
expect(readCall).toMatchObject({
|
||||
sessionUpdate: 'tool_call', toolCallId: 'r1', title: 'Read src/a.ts', kind: 'read',
|
||||
rawInput: 'offset 12', locations: [{ path: 'src/a.ts', line: 12 }],
|
||||
})
|
||||
|
||||
const [editCall] = updatesWith(presenter, evt('tool/call', {
|
||||
turn: 1, step: 1, callId: CallId('e1'), name: 'edit',
|
||||
arguments: JSON.stringify({ file_path: 'src/b.ts', old_string: 'x', new_string: 'y' }),
|
||||
}))
|
||||
expect(editCall).toMatchObject({
|
||||
sessionUpdate: 'tool_call', toolCallId: 'e1', title: 'Edit src/b.ts', kind: 'edit',
|
||||
locations: [{ path: 'src/b.ts' }],
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal-card mapping (capability-gated)', () => {
|
||||
|
||||
Reference in New Issue
Block a user