From aa62b5109a1736e5b468c2652d52cb635bb6cc12 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 13 Jul 2026 16:31:03 +0800 Subject: [PATCH] Fix workspace context review findings --- docs/config-catalog.md | 4 +- docs/cordis-catalog/events.md | 32 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.md | 12 +- docs/event-producer-consumer.md | 28 +- .../2026-07-02-fs-per-session-cwd.md | 2 +- .../2026-07-05-reconstructable-requests.md | 4 +- .../feature/2026-06-24-workspace-context.md | 12 +- .../feature/2026-06-30-hook-bridges.md | 4 +- .../feature/2026-06-30-interception-seams.md | 6 +- .../feature/2026-07-07-session-prefix.md | 2 +- .../2026-06-26-fsspec-style-fs-seam.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/loop.ts | 17 +- .../agent-loop/tests/interception.spec.ts | 12 +- packages/core/agent/README.md | 2 + packages/core/agent/src/types.ts | 26 +- packages/fs/fs-local/README.md | 2 +- packages/fs/fs-local/src/index.ts | 4 +- packages/fs/fs-local/tests/filesystem.spec.ts | 12 + packages/fs/fs/README.md | 2 +- packages/fs/fs/src/index.ts | 7 +- packages/fs/tool-fs/README.md | 2 +- packages/fs/tool-fs/src/edit.ts | 5 +- packages/fs/tool-fs/src/read.ts | 5 +- packages/fs/tool-fs/src/write.ts | 5 +- packages/hooks/hooks-claude/README.md | 2 +- packages/hooks/hooks-claude/src/index.ts | 19 +- .../hooks/hooks-claude/tests/coverage.spec.ts | 18 +- packages/hooks/hooks-codex/README.md | 2 +- packages/hooks/hooks-codex/src/index.ts | 18 +- .../hooks/hooks-codex/tests/coverage.spec.ts | 16 +- packages/prompt/workspace-context/README.md | 13 +- .../prompt/workspace-context/src/config.ts | 6 + .../prompt/workspace-context/src/digest.ts | 2 +- .../prompt/workspace-context/src/files.ts | 146 ++++--- .../prompt/workspace-context/src/index.ts | 45 +- .../prompt/workspace-context/src/state.ts | 70 +++- .../tests/workspace-context.spec.ts | 386 ++++++++++++++++-- 40 files changed, 708 insertions(+), 252 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c1c003db9c..a8ce6289db 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1150,12 +1150,14 @@ export interface Config { projectRootMarkers?: string[] /** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */ maxBytes: number + /** Maximum UTF-8 bytes read from one instruction file; larger files are ignored. */ + maxSourceBytes?: number /** Ordered same-directory project candidates; the first existing regular file wins in each scope. */ instructionFileCandidates?: string[] } ``` -Source: [`packages/prompt/workspace-context/src/config.ts:15`](../packages/prompt/workspace-context/src/config.ts) +Source: [`packages/prompt/workspace-context/src/config.ts:16`](../packages/prompt/workspace-context/src/config.ts) ## Loadable plugins with no config diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 1f3ee997f2..9cec7c53fb 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -23,7 +23,7 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:330`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:332`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,7 @@ An agent was removed from the registry. The concrete AgentLoop lifecycle emits t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:347`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:619`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:621`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -61,11 +61,11 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:452`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:454`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall -Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it. Fires inside the already-open turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook. Call `next()` to delegate to the default (allow unchanged), or return a PromptDecision without calling `next()` to short-circuit. +Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContexts`) or block it. Fires inside the already-open turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook. Call `next()` to delegate to the default (allow unchanged), or return a PromptDecision without calling `next()` to short-circuit. ```ts cordis-catalog 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise @@ -73,7 +73,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:470`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:472`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -85,11 +85,11 @@ A message entered the agent's inbox (queued or steering). Content and the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:374`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:376`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall -Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use). Config is ALL a listener shapes here: every request is a pure function of the session log (the reconstructability RFC), so model-visible content flows through the log channels — `inject()`, steering, prompt-submit `additionalContext`, prompt sections via `system-prompt/assemble`, or the header-logged session prefix via agent/session-prefix — never through request mutation, and the loop records whatever config the request actually uses as a `request/header*` event before dispatch. The step's messages are already snapshotted when this fires (the `step/start` boundary): an `inject()` from a listener here lands in the log but joins the NEXT request. For surface mutation that must precede the snapshot (compaction), use agent/pre-step. Call `next()` to delegate, or return an LlmCallConfig without it to short-circuit. +Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use). Config is ALL a listener shapes here: every request is a pure function of the session log (the reconstructability RFC), so model-visible content flows through the log channels — `inject()`, steering, prompt-submit `additionalContexts`, prompt sections via `system-prompt/assemble`, or the header-logged session prefix via agent/session-prefix — never through request mutation, and the loop records whatever config the request actually uses as a `request/header*` event before dispatch. The step's messages are already snapshotted when this fires (the `step/start` boundary): an `inject()` from a listener here lands in the log but joins the NEXT request. For surface mutation that must precede the snapshot (compaction), use agent/pre-step. Call `next()` to delegate, or return an LlmCallConfig without it to short-circuit. ```ts cordis-catalog 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise @@ -97,13 +97,13 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:499`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:501`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider's system slot) on every request this loop instance sends. Fired ONCE per loop instance, lazily before its first step's agent/pre-step seam — BEFORE the pre-step so a token-pressure gate (compaction) counts the prefix this instance will actually send, never a previous instance's logged one. The composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the instance's anchoring `'initial'`/`'resume'` header snapshot, and reused verbatim for every subsequent request — never recomputed mid-session, so the provider prefix cache holds by construction (a process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` snapshot). Composition runs outside the step, before the boundary snapshot: a composing listener's session append joins the CURRENT request's derived history. A composition interrupted by a cancel/dispose landing inside the waterfall is discarded — never cached, logged, or sent — and the next turn recomposes under a live signal, so an abort-aware listener's degraded fallback cannot leak into later requests. -This is the home for session-stable openers the model must always see but that must NOT become durable history — a skills catalog, an AGENTS.md digest, a workspace baseline: `Session.deriveMessages()` never returns the prefix, and the header events are its only durable record, so the request stays reconstructable from the log. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter. +This is the home for session-stable openers the model must always see but that must NOT become durable history — a skills catalog, an AGENTS.md digest, a workspace baseline: `Session.deriveMessages()` never returns the prefix, and the header events are its only durable record, so the request stays reconstructable from the log. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, a `tools/post-execute` decision's `additionalContexts`, prompt-submit `additionalContexts` — each a durable `context/message` paid once and prefix-cached thereafter. The seed is a frozen empty list; a contributing listener returns a NEW array — never an in-place push. The canonical contribution is a PREPEND, `[mine, ...await next()]`: the waterfall unwinds innermost-first (the LAST-registered listener's `next()` resolves first), so prepending yields registration order on the wire, and every plugin using it composes deterministically. The append form `[...await next(), mine]` is legal but places a contribution AFTER every later-registered plugin's — reverse registration order when all contributors append. Call `next()` to delegate, or return a list without it to short-circuit. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered through `agent.ctx` fires only for that agent's dispatches; a listener on a plain plugin context fires for every agent. The dispatch `this` is the scope carrier (`Scoped`), built by the emitting side via `scopeTarget`/`agentEvents`. @@ -113,7 +113,7 @@ The seed is a frozen empty list; a contributing listener returns a NEW array — Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:551`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:553`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -125,7 +125,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:395`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:397`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:359`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:361`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -149,7 +149,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:566`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:568`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:584`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:586`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -173,7 +173,7 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:602`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:604`](../../packages/core/agent/src/types.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5ebb953d67..b11922e8bd 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -135,7 +135,7 @@ Semantics every backend must honor: - 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`). ```ts cordis-catalog -abstract resolve(path: string, opts?: { cwd?: string }): Promise +abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise abstract stat(target: FsTarget, signal?: AbortSignal): Promise abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise abstract readText(target: FsTarget, signal?: AbortSignal): Promise diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 0fab16874c..49eb866f55 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -368,7 +368,7 @@ interface Agent { ## Interception decisions -Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. They share one model-facing context shape, `HookContext`, which is `inject()`ed as a `context/message` and therefore carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its optional `envelope` selects the canonical context tag or caller-owned raw framing, while JSON `meta` persists plugin state without exposing it to the model. Prompt submission carries at most one `additionalContext`; post-tool decisions and results carry `additionalContexts[]` so nested dispatches preserve each entry's provenance and metadata. +Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which is `inject()`ed as a `context/message` and therefore carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its optional `envelope` selects the canonical context tag or caller-owned raw framing, while JSON `meta` persists plugin state without exposing it to the model. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, framing, and metadata. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -381,20 +381,20 @@ interface HookContext { } ``` -`agent/prompt-submit` returns a `PromptDecision` (allow a drained queued message — optionally rewriting its `content` or attaching `additionalContext` — or block it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`): +`agent/prompt-submit` returns a `PromptDecision` (allow a drained queued message — optionally rewriting its `content` or attaching `additionalContexts` — or block it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`): ```ts type-equiv type PromptDecision = - | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext } + | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'block'; reason: string } ``` -`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern): +`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn and therefore carries no context envelope or metadata — the typed `/goal` pattern): ```ts type-equiv type ContinuationDecision = | { action: 'stop' } - | { action: 'continue'; reason?: HookContext } + | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } ``` `agent/turn-stop` returns the stop-only `ContinuationStop` subset or `undefined`. The loop calls this serial checkpoint after folding the ordinary decision, its reason, and pending steering; a stop is terminal and discards pending steering. @@ -409,7 +409,7 @@ type ContinuationStop = Extract type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ``` -`agent/session-prefix` composes the session prefix — a plain `Message[]`, no dedicated payload type. Fired ONCE per loop instance, lazily on its first request: the composed list is deep-frozen, recorded as the header's `messagePrefix` ([the request envelope](#the-request-envelope-llmcallconfig-and-the-logged-header)), and placed in front of the ENTIRE derived history on every request the instance sends — the home for session-stable openers like a skills catalog or an AGENTS.md digest, never returned by `deriveMessages()`. Reuse is structural, so the prefix cannot drift mid-session (resume = a new instance = a recompose); content that changes mid-session goes through the append-only history channels instead (`agent.inject()`, tool `additionalContexts`, prompt-submit `additionalContext`). Not a Decision union: the seam contributes content instead of vetoing, so the shape is the contribution itself. +`agent/session-prefix` composes the session prefix — a plain `Message[]`, no dedicated payload type. Fired ONCE per loop instance, lazily on its first request: the composed list is deep-frozen, recorded as the header's `messagePrefix` ([the request envelope](#the-request-envelope-llmcallconfig-and-the-logged-header)), and placed in front of the ENTIRE derived history on every request the instance sends — the home for session-stable openers like a skills catalog or an AGENTS.md digest, never returned by `deriveMessages()`. Reuse is structural, so the prefix cannot drift mid-session (resume = a new instance = a recompose); content that changes mid-session goes through the append-only history channels instead (`agent.inject()` and tool/prompt-submit `additionalContexts`). Not a Decision union: the seam contributes content instead of vetoing, so the shape is the contribution itself. ## `ToolDefinition` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 054a12d566..bbbee93027 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,19 +7,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:330`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:619`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:452`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:470`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:374`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:499`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:551`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/prompt/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:359`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:566`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:584`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:602`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:332`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:347`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:621`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:454`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:472`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:376`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:501`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:553`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/prompt/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:397`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:361`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:568`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:586`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:604`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:70`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:125`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:140`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -41,7 +41,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:128`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:148`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`workspace-context`](../packages/prompt/workspace-context) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:101`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/prompt/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:96`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:85`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:106`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md index d0656c9327..fabe1b6624 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md +++ b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md @@ -12,7 +12,7 @@ The filesystem tools did NOT honor this. `ctx.fs.resolve(path)` took no caller c Thread the caller's session cwd into path resolution, exactly as `dsh-tool-bash` already does for `workdir`. The **caller** (the tool) supplies the cwd; the provider does not read a session or agent. -- `FileSystem.resolve` widens to `resolve(path: string, opts?: { cwd?: string }): Promise`. `opts.cwd` is the base a RELATIVE `path` resolves against; an absolute `path` ignores it; omitting `opts.cwd` uses the backend's own default. An options object (not a positional `cwd?`) leaves room for future resolution hints without another signature change. +- `FileSystem.resolve` accepts `resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise`. `opts.cwd` is the base a RELATIVE `path` resolves against; an absolute `path` ignores it; omitting `opts.cwd` uses the backend's own default. `opts.signal` cancels resolution when the backend performs I/O. The options object keeps both caller-owned resolution controls together without positional growth. - `dsh-fs-local.resolve` uses `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`. `config.cwd` stays the default for a caller that supplies none (non-ACP / no-session use, and the single-session stdio demo where `process.cwd()` IS the workspace). - `dsh-tool-fs`'s `read`/`write`/`edit` derive the session cwd through a shared `sessionCwd(exec)` helper (`exec.agent?.session.header.cwd`, mirroring bash's `resolveWorkdir`) and pass it to `resolve`. A non-agent / headerless caller yields `undefined`, so the backend applies its default. diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md index 4ba662b696..bd6ddcfa20 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -22,7 +22,7 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro **The header.** The request's non-history half — `EpochHeader`: call config (`LlmCallConfig`: model + sampling scalars), rendered system prompt, assembled tool schemas, and the session prefix (`messagePrefix`, below) — is logged session state, in canonical form (empty system/tools/prefix ≡ absent). Two log-only, turn-enclosed events in dsh-session carry it: `request/header`, a full snapshot with reason `'initial' | 'resume' | 'fallback'`, and `request/header-delta`, an amendment (`SystemDelta`: a common-prefix/suffix line trim; `ToolsDelta`: name-keyed added/removed/changed; `config`: replaced whole; `messagePrefix`: replaced whole, an empty array encoding the transition to absence — an arm the loop never exercises in practice, kept for codec totality). The pure trio `foldRequestHeader` / `diffHeader` / `applyHeaderDelta` reconstructs; the live session tracks the fold with the same lazy cursor as the message cache. Snapshots anchor the fold where a fold needs anchors — conversation birth and process boundaries — and each loop instance appends one on its first request (`'initial'` when the log has none, `'resume'` otherwise, even when nothing changed: the boundary itself is a recorded fact, and cross-restart drift becomes attributable while an unchanged header resumes byte-identical). Deltas are an encoding optimization with a safety valve, never a correctness dependency: the writer verifies `applyHeaderDelta(prev, delta)` reproduces the new header exactly and records a `'fallback'` snapshot when the encoding cannot express a change (a pure tool reordering), so a well-formed log always folds. -**The loop, transmission-stateless.** Per step: render assembly (every step — value comparison needs no change-signal discipline, and a section that varies per step surfaces as a *logged* header event per step instead of a silent bust) → on the instance's FIRST step only, the `agent/session-prefix` waterfall — request-ONLY messages fronting the entire derived history (a frozen empty seed, contributions returned as an extension of `next()`; the home for session-stable openers that must NOT become history — a skills catalog, an AGENTS.md digest), deep-frozen and cached on the instance so reuse is structural and the prefix cannot drift mid-session — → `agent/pre-step`, carrying the composed prefix (compaction's surface mutations land before derivation, and its pressure gate counts the prefix this instance will actually send — never a previous instance's logged one, which could under-gate a resumed/forked instance whose contributor grew) → **messages snapshot, then `step/start` appended as the next operation in the same synchronous frame** → seed the call config (first request of the instance: from `AgentOptions`, so explicit options always beat the logged baseline — fork model-overrides and resume reconfiguration stay correct; afterwards: from the folded header) → the `agent/request` waterfall, re-typed `(agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig` — a frozen seed and a returned replacement are ALL a listener shapes; durable content flows through the log channels (`inject()`, steering, prompt-submit `additionalContext`, sections via `system-prompt/assemble`) — → the header event the request owes the log, carrying the prefix as `messagePrefix` (no session event carries it, so the header is its only durable record; resume = a new instance = a recompose, anchored by its `'resume'` snapshot) → build `GenerateOptions` from `messagePrefix + snapshot` + header, deep-freeze (`deepFreeze` exempts the `AbortSignal`, the one live control channel — freezing one breaks `AbortController.abort()`), dispatch. The loop's per-instance bookkeeping is one boolean plus the cached prefix: whether this instance has logged its anchoring snapshot, and what it composed. +**The loop, transmission-stateless.** Per step: render assembly (every step — value comparison needs no change-signal discipline, and a section that varies per step surfaces as a *logged* header event per step instead of a silent bust) → on the instance's FIRST step only, the `agent/session-prefix` waterfall — request-ONLY messages fronting the entire derived history (a frozen empty seed, contributions returned as an extension of `next()`; the home for session-stable openers that must NOT become history — a skills catalog, an AGENTS.md digest), deep-frozen and cached on the instance so reuse is structural and the prefix cannot drift mid-session — → `agent/pre-step`, carrying the composed prefix (compaction's surface mutations land before derivation, and its pressure gate counts the prefix this instance will actually send — never a previous instance's logged one, which could under-gate a resumed/forked instance whose contributor grew) → **messages snapshot, then `step/start` appended as the next operation in the same synchronous frame** → seed the call config (first request of the instance: from `AgentOptions`, so explicit options always beat the logged baseline — fork model-overrides and resume reconfiguration stay correct; afterwards: from the folded header) → the `agent/request` waterfall, re-typed `(agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig` — a frozen seed and a returned replacement are ALL a listener shapes; durable content flows through the log channels (`inject()`, steering, prompt-submit `additionalContexts`, sections via `system-prompt/assemble`) — → the header event the request owes the log, carrying the prefix as `messagePrefix` (no session event carries it, so the header is its only durable record; resume = a new instance = a recompose, anchored by its `'resume'` snapshot) → build `GenerateOptions` from `messagePrefix + snapshot` + header, deep-freeze (`deepFreeze` exempts the `AbortSignal`, the one live control channel — freezing one breaks `AbortController.abort()`), dispatch. The loop's per-instance bookkeeping is one boolean plus the cached prefix: whether this instance has logged its anchoring snapshot, and what it composed. **The reconstruction boundary is `step/start`, unconditionally.** A step's messages are the derivation over `events[0..stepStartSeq)`. Because the snapshot precedes the `step/start` append in the same synchronous frame, an `agent.inject()` from an `agent/request` listener or any concurrent task lands after the boundary and joins the NEXT request. `session/event` is observe-only during publication: a reentrant append is rejected until the current callback list drains, preventing nested event delivery from overtaking the event being observed. `agent/pre-step` is the sanctioned seam for content that must affect the CURRENT request. A step's header for reconstruction is the fold after its own `request/header*` event (which sits between its `step/start` and first response event) or the fold carried forward. @@ -44,7 +44,7 @@ What survives from `LLMClient`: the conversation is maintained, not rebuilt — ## Consequences - A request that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event. -- Choosing between the advisory channels is a change-frequency decision, and the design makes the stable one structural: an `agent/session-prefix` contribution is composed once per loop instance and reused verbatim, so it extends the cacheable prefix at zero marginal cost and CANNOT bust the provider cache mid-session; content that changes mid-session flows through the append-only history channels — `agent.inject()`, tool-result `additionalContexts`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter, at the price of accumulating in history and the log. Route session-frozen openers to the prefix and change notices to the history channels; a per-step request-only tail slot was deliberately dropped (no consumer, and a durable append covers every current update pattern). +- Choosing between the advisory channels is a change-frequency decision, and the design makes the stable one structural: an `agent/session-prefix` contribution is composed once per loop instance and reused verbatim, so it extends the cacheable prefix at zero marginal cost and CANNOT bust the provider cache mid-session; content that changes mid-session flows through the append-only history channels — `agent.inject()` and tool/prompt-submit `additionalContexts` — each a durable `context/message` paid once and prefix-cached thereafter, at the price of accumulating in history and the log. Route session-frozen openers to the prefix and change notices to the history channels; a per-step request-only tail slot was deliberately dropped (no consumer, and a durable append covers every current update pattern). - What still costs full price at the provider is inherent and logged: compaction (its `compact/*` events and replace node), a real prompt/tool change (`request/header-delta`), a config switch (ditto), a process boundary with drift (`'resume'` snapshot differing from its predecessor). The provider's own reasoning-content exclusion is managed server-side. - The `step/start`-listener behavior change (above) is the one observable semantics change for plugins; `agent/pre-step` is the current-request seam. - Tool-result trimming (planned) needs no new mechanism: a logged single-node surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. diff --git a/docs/rfc/implemented/feature/2026-06-24-workspace-context.md b/docs/rfc/implemented/feature/2026-06-24-workspace-context.md index 501ba8a1c8..31372959cf 100644 --- a/docs/rfc/implemented/feature/2026-06-24-workspace-context.md +++ b/docs/rfc/implemented/feature/2026-06-24-workspace-context.md @@ -14,7 +14,7 @@ The lifecycle has two distinct classes of content. The initial applicable chain The implementation lives in `packages/prompt/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a prompt/context extension, not a core service or a filesystem backend. `@deepseek-ai/dsh-agent-core` mounts it for both product front doors and forwards its config. The plugin consumes `agent/session-prefix`, `tools/post-execute`, and the optional `ctx.fs` capability. -The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes call `lstat` before `resolve`, so a repository-owned final-component symlink is rejected rather than followed outside the workspace. A provider exception or disagreement after `lstat` is classified as unavailable and never interpreted as a deletion. +The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes call `lstat` before `resolve`, so a repository-owned final-component symlink is rejected rather than followed outside the workspace. The session-prefix signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A provider exception or disagreement after `lstat` is classified as unavailable and never interpreted as a deletion. ### File Names And Precedence @@ -48,7 +48,7 @@ Shell commands are not discovery triggers. Local bash calls start fresh shells, Every dynamic workspace context event stores versioned metadata with `{ action, scope, path, previousPath?, digest? }`, where `digest` is SHA-1 over the loaded content. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state. -At reconciliation time the plugin scans plugin-owned `context/message` events and derives the latest state for each visible scope. A short per-session pending map covers the interval after `tools/post-execute` returns an `additionalContexts` entry but before the loop appends that context to the log. Once an equal event appears at or after the pending sequence boundary, the pending entry is removed. +At reconciliation time the plugin scans plugin-owned `context/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy. Once an equal event appears at or after the pending sequence boundary, the pending entry is removed. An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch. @@ -56,11 +56,11 @@ The frozen baseline keeps an in-memory path/digest map for comparison. A later s There is intentionally no watcher. Detection occurs at the next successful structured filesystem touch or resumed prefix composition. A provider failure produces no removal; absence is only accepted when all configured candidates in that scope were probed successfully. -### Byte Budget And Cache +### Byte Budget And Bounded Reads -`maxBytes` is required and applies separately to a rendered baseline or one dynamic reconciliation batch; there is no implicit or unbounded budget. Non-positive and non-finite values disable loading. When content exceeds the budget, broader files are omitted before the most-specific file is truncated. A visible `Workspace instruction budget ...` notice names omitted and truncated paths and byte counts, and output never exceeds the configured bytes. +`maxBytes` is required and applies separately to a rendered baseline or one dynamic reconciliation batch; there is no implicit or unbounded render budget. Non-positive and non-finite values disable loading. When content exceeds the budget, broader files are omitted before the most-specific file is truncated. A visible `Workspace instruction budget ...` notice names omitted and truncated paths and byte counts, and output never exceeds the configured bytes. -Each discovered candidate is read and identified by normalized absolute path, the provider's opaque version, and a SHA-1 content digest. Hashing the read content prevents same-version, same-size rewrites from staying stale. Discovery carries the provider version into the read pass so one pass does not stat the same instruction twice. Visible structured metadata remains the source of duplicate-suppression state. +`maxSourceBytes` is a positive per-file cap with a 1 MiB default. The loader checks reported size before reading and still consumes content through `streamText()` with a running UTF-8 byte count, so missing/stale metadata cannot force an unbounded allocation. An oversized winning candidate is unavailable rather than a reason to fall through to another same-directory name. The plugin deliberately keeps no process-wide content cache: every reconciliation observes the current bounded text, then computes the SHA-1 used by visible structured duplicate-suppression state. ## Alternatives considered @@ -76,7 +76,7 @@ Each discovered candidate is read and identified by normalized absolute path, th ## Consequences -Workspace guidance is isolated per session and shared by both product front doors and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract includes optional raw framing and JSON metadata, both propagated through prompt-submit `additionalContext` and post-tool `additionalContexts` paths. +Workspace guidance is isolated per session and shared by both product front doors and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract includes optional raw framing and JSON metadata, both propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries. Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, delimiter escaping, and symlink rejection reduce risk but do not eliminate prompt injection. Permission and sandbox layers treat workspace files as data rather than authority. diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md index 65bd4b0c3c..4c2388ee3a 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md @@ -35,9 +35,9 @@ The CC bridge's `ask` result is a real permission path, not a terminal bridge de `agent.inject()` defaults a missing `MessageSource` to `{ kind: 'user' }` — which would record plugin-injected context as if the user had typed it. So every bridge `inject()` and every `HookContext` passes an explicit `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }` source. A test asserts the resulting `context/message.source` is the plugin, never `user`. -### Adding context is not a veto — delegate, then fold +### Adding context is not a veto — delegate, then prepend -A hook that only attaches `additionalContext` (no block/deny) is NOT a decision the bridge should return on its own: returning `allow`/`accept` from a waterfall listener WITHOUT calling `next()` short-circuits every later `agent/prompt-submit` / `tools/post-execute` listener, so a policy/sandbox plugin registered after the bridge would never see the prompt. Each bridge therefore delegates via `next()` before adding its context to the downstream decision. The two seams differ: `tools/post-execute` carries an ordered `additionalContexts` array, so the bridge prepends its separately sourced context while preserving a downstream `block` or `accept`; Code Mode ferries the same array through the outer `run_code` result. `agent/prompt-submit` still has one `additionalContext`, so an allowed downstream contribution is folded into one context while a downstream block drops it because a blocked prompt never reaches the model. Only a real `deny`/`block` from the hook itself short-circuits. Tests assert a later listener can still block a prompt a context-only hook allowed and that every post-tool context retains its own source, envelope, and metadata. +A hook that only attaches `additionalContext` (no block/deny) is NOT a decision the bridge should return on its own: returning `allow`/`accept` from a waterfall listener WITHOUT calling `next()` short-circuits every later `agent/prompt-submit` / `tools/post-execute` listener, so a policy/sandbox plugin registered after the bridge would never see the prompt. Each bridge therefore delegates via `next()` before adding its context to the downstream decision. Both seams carry ordered `additionalContexts` arrays, so the bridge prepends its separately sourced entry while preserving every downstream source, envelope, and metadata field; a downstream prompt block still drops all context because the prompt never reaches the model, while post-tool block semantics may explicitly retain contexts. Code Mode ferries the same array through the outer `run_code` result. Only a real `deny`/`block` from the hook itself short-circuits. Tests assert a later listener can still block a prompt a context-only hook allowed and that retained prompt and post-tool contexts remain separate. ### CLAUDE_PROJECT_DIR defaults to the session workspace diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index 6973535aed..cbb93d136c 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -14,9 +14,9 @@ The canonical surface separates transformable policy, around-dispatch control, a **Agent events** (`dsh-agent`): - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. -- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below). +- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching separately sourced `additionalContexts[]`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below). -**`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. +**`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing content and source recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. It is not a `context/message`, so its type does not offer a context envelope or durable context metadata. ### The tool pipeline gives each phase one kind of authority @@ -34,7 +34,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li ### Three load-bearing loop decisions -1. **Always open the turn first; a fully-blocked batch is a zero-step `rejected` turn; every veto is recorded as `prompt/blocked`.** `prompt-submit` fires AFTER `turn/start`, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes with `rejected`. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) the durable `turn/end` is appended and the ACP bridge settles normally off it (mapping `rejected`→`cancelled`) instead of hanging; (3) the block reason is a durable in-turn fact. Independently, each individual veto appends a `prompt/blocked` session event (the original `content`, `source`, and `reason`) in place of the `user/message` the prompt would have become — necessary because a MIXED batch (one prompt blocked, another allowed) does NOT end `rejected`, so the boundary reason alone would silently lose the blocked prompt on replay. An `allow`'s `additionalContext` is `inject()`ed into this now-open turn. +1. **Always open the turn first; a fully-blocked batch is a zero-step `rejected` turn; every veto is recorded as `prompt/blocked`.** `prompt-submit` fires AFTER `turn/start`, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes with `rejected`. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) the durable `turn/end` is appended and the ACP bridge settles normally off it (mapping `rejected`→`cancelled`) instead of hanging; (3) the block reason is a durable in-turn fact. Independently, each individual veto appends a `prompt/blocked` session event (the original `content`, `source`, and `reason`) in place of the `user/message` the prompt would have become — necessary because a MIXED batch (one prompt blocked, another allowed) does NOT end `rejected`, so the boundary reason alone would silently lose the blocked prompt on replay. An `allow`'s `additionalContexts` are individually `inject()`ed into this now-open turn. 2. **Post-tool `additionalContexts` are buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but each context is a SEPARATE `context/message`, and a single step or composite tool can produce many. Appending context immediately would interleave `result(c1) → context → result(c2)` or place nested context before its outer result, breaking tool-call/result adjacency. `ToolRunContext.deferContext()` therefore collects nested-dispatch context through failures, `execute()` surfaces the ordered array on `ToolExecutionResult`, and the loop appends every entry only after every `tool/result` in the step. An accepted outer call preserves deferred contexts before decision contexts; an outer block discards deferred contexts and exposes only contexts explicitly supplied by the blocking decision. diff --git a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md index 6f81d12407..00dc792a78 100644 --- a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md @@ -15,7 +15,7 @@ The obvious third option — let a plugin edit the request's `messages` on the w Three properties carry the design: - **Request-only, header-logged.** `deriveMessages()` never returns the prefix; its one durable record is `EpochHeader.messagePrefix` on the instance's anchoring `request/header` snapshot — the channel the reconstructable-requests RFC already owns for the request's non-history half, so no new session event exists. The dev invariant ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)) recomputes `messagePrefix + boundary derivation` against every loop-built request; an unlogged prefix cannot reach the wire. -- **Frozen per instance.** Reuse is structural, not disciplined: the cached product cannot change mid-session, so the provider's prompt cache holds by construction and the prefix extends the cacheable region at zero marginal cost per step. A process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` header snapshot. This is the routing rule the seam creates: session-frozen openers ride the prefix; content that changes mid-session rides the append-only history channels (`agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — [the interception-seams RFC](2026-06-30-interception-seams.md)), each a durable `context/message` paid once and prefix-cached thereafter. +- **Frozen per instance.** Reuse is structural, not disciplined: the cached product cannot change mid-session, so the provider's prompt cache holds by construction and the prefix extends the cacheable region at zero marginal cost per step. A process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` header snapshot. This is the routing rule the seam creates: session-frozen openers ride the prefix; content that changes mid-session rides the append-only history channels (`agent.inject()` or tool/prompt-submit `additionalContexts` — [the interception-seams RFC](2026-06-30-interception-seams.md)), each a durable `context/message` paid once and prefix-cached thereafter. - **Composed before the pressure gate.** Composition precedes the instance's first `agent/pre-step`, and the seam hands the composed value through: `agent/pre-step` carries a `sessionPrefix` parameter and `CompactService.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` counts it in its token-pressure estimate — a gate reading the previous instance's folded prefix instead would under-gate a resumed or forked instance whose contributor grew, skipping compaction and shipping an over-window first request. A composition interrupted by a cancel/dispose landing inside the waterfall is discarded, never cached: an abort-aware listener's degraded fallback cannot leak into later requests, and the next turn recomposes under a live signal. Because composition runs before the boundary snapshot, a composing listener's session append joins the CURRENT request's derived history. Compaction structurally cannot touch the prefix (or the system prompt): it rewrites surface nodes, and header state never enters the surface. diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md index 2663637859..d2b4c6dc4f 100644 --- a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md @@ -35,7 +35,7 @@ This RFC decided the four-layer split, the provider contract, and the freshness `@deepseek-ai/dsh-fs` shrinks to provider text IO plus guarded text mutation: ```ts ignore-check -abstract resolve(path: string): Promise +abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise abstract stat(target: FsTarget, signal?: AbortSignal): Promise abstract readText(target: FsTarget, signal?: AbortSignal): Promise abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 52a517d5cc..66fbacd60b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -116,7 +116,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'fs', summary: 'Abstract filesystem provider service.', methods: [ - 'abstract resolve(path: string, opts?: { cwd?: string }): Promise', + 'abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise', 'abstract stat(target: FsTarget, signal?: AbortSignal): Promise', 'abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise', 'abstract readText(target: FsTarget, signal?: AbortSignal): Promise', @@ -265,7 +265,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/prompt-submit', mode: 'waterfall', signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise', - summary: 'Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it.', + summary: 'Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContexts`) or block it.', }, { name: 'agent/queued', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 83de9b9dee..f011f96636 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -59,7 +59,7 @@ forever: TURN (error-contained): 'turn/start' each queued: waterfall agent/prompt-submit → allow (→ session('user/message'), - inject additionalContext) | block (→ session('prompt/blocked'), drop) + inject each additionalContexts entry) | block (→ session('prompt/blocked'), drop) if every prompt blocked: 'turn/end'(rejected), no step ⟵ zero-step turn STEP loop: drain steering diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index c761196717..ad67d1ad84 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -155,7 +155,7 @@ export interface LoopHandle { * wait for queued messages (idle) * TURN (error-contained — a throwing plugin ends the turn, never the loop): * 'turn/start'; each queued msg: waterfall agent/prompt-submit ⟵ durable turn boundary (no agent/* mirror) - * allow → session('user/message'…) (+ inject additionalContext) | block → drop + * allow → session('user/message'…) (+ inject additionalContexts) | block → drop * every prompt blocked → 'turn/end'(rejected), 0 steps * STEP loop: * drain steering → session('steering/message') ⟵ catches late steering @@ -406,13 +406,14 @@ async function runTurn( // `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them. const content = decision.content ?? message.content session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' }) - // `allow.additionalContext` is a SEPARATE context/message the next request - // also sees. The turn is open, so inject() appends it into THIS turn. - if (decision.additionalContext) { - agent.inject(decision.additionalContext.content, { - source: decision.additionalContext.source, - ...decision.additionalContext.envelope !== undefined ? { envelope: decision.additionalContext.envelope } : {}, - ...decision.additionalContext.meta !== undefined ? { meta: decision.additionalContext.meta } : {}, + // Every `allow.additionalContexts` entry is a separate context/message the + // next request also sees. The turn is open, so inject() appends each one + // into THIS turn without flattening provenance, framing, or metadata. + for (const context of decision.additionalContexts ?? []) { + agent.inject(context.content, { + source: context.source, + ...context.envelope !== undefined ? { envelope: context.envelope } : {}, + ...context.meta !== undefined ? { meta: context.meta } : {}, }) } } diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 3f78bb8d8b..5f3b49da6f 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -17,7 +17,7 @@ import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' * The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`, * `agent/session-start`, the reshaped `agent/turn-continuation` * ({@link ContinuationDecision}), and the `tools/pre-execute` / `tools/post-execute` - * split with `additionalContext` buffering. These verify the canonical event + * split with `additionalContexts` buffering. These verify the canonical event * surface a hook bridge (or a native plugin) programs against, WITHOUT any * external protocol — a native plugin uses the typed decisions directly. */ @@ -91,7 +91,7 @@ describe('agent/prompt-submit', () => { expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original') }) - it('allow with additionalContext injects a separate context/message into the turn', async () => { + it('allow with additionalContexts injects separate context/message events into the turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -100,12 +100,12 @@ describe('agent/prompt-submit', () => { ctx.on('agent/prompt-submit', async (): Promise => ({ kind: 'allow', - additionalContext: { + additionalContexts: [{ content: [{ type: 'text', text: 'extra ctx' }], source: { kind: 'plugin', plugin: 'test' }, envelope: 'raw', meta, - }, + }], })) send(agent, 'go') @@ -124,7 +124,7 @@ describe('agent/prompt-submit', () => { expect(sent).toContain('extra ctx') }) - it('a prompt-submit rewrite + additionalContext is VISIBLE to the agent/pre-step seam (merged ordering)', async () => { + it('a prompt-submit rewrite + additionalContexts is VISIBLE to the agent/pre-step seam (merged ordering)', async () => { // The merge of the interception seams with master's compaction seam pins one // ordering: `agent/prompt-submit` runs (rewriting the prompt and injecting // context) BEFORE the step loop, and `agent/pre-step` fires INSIDE the step @@ -141,7 +141,7 @@ describe('agent/prompt-submit', () => { ({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN prompt' }], - additionalContext: { content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } }, + additionalContexts: [{ content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } }], })) // The pre-step seam (where compaction lives) derives the surface it would act diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 1a3aea3509..dc2f33b7b3 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -33,6 +33,8 @@ The lifecycle edges have two important local caveats. `agent/created` runs after Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). +`PromptDecision.additionalContexts` is an array so every injected context keeps its own source, envelope, and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source. + Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). ### Agent interface (`types.ts`) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 1f921eca75..df052f5b0f 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -121,8 +121,8 @@ export type AgentStatus = 'idle' | 'running' | 'disposed' /** * Model-facing context an interception listener wants the agent to SEE on the * next request — the canonical shape behind every "inject extra context" - * decision ({@link PromptDecision}, {@link PostToolDecision}, - * {@link ContinuationDecision}). It is `agent.inject()`ed as a + * decision ({@link PromptDecision}, {@link PostToolDecision}). It is + * `agent.inject()`ed as a * `context/message`, so it carries a REQUIRED {@link MessageSource}: `inject()` * defaults a missing source to `{kind:'user'}`, which would MISLABEL plugin * context as a user prompt and corrupt derived history. A bridge sets @@ -144,8 +144,8 @@ export interface HookContext { * the Claude Code `UserPromptSubmit` hook's allow/block + `additionalContext`. * * - `allow` proceeds with the prompt; optional `content` REPLACES the prompt - * bytes (a rewrite), and optional `additionalContext` is `inject()`ed as a - * separate `context/message` the next request also sees. + * bytes (a rewrite), and optional `additionalContexts` are each `inject()`ed + * as separate `context/message` events the next request also sees. * - `block` drops the prompt (it never becomes a `user/message`); `reason` is * the durable record of why. The loop appends a `prompt/blocked` session event * (carrying the original content, source, and `reason`) in place of the @@ -156,7 +156,7 @@ export interface HookContext { * hook"). */ export type PromptDecision = - | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext } + | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'block'; reason: string } /** @@ -165,14 +165,16 @@ export type PromptDecision = * calls or steering was injected, else `stop`); listeners override it to * force-continue (`/goal`, `/loop`) or force-stop (budget guards). * - * A `continue` may carry a `reason`: model-facing context recorded as next-STEP + * A `continue` may carry a `reason`: model-facing content recorded as next-STEP * steering within the SAME turn (the loop enqueues it through the steering - * channel, so the continued turn's next step sees it). This is the typed twin of - * the existing "steer from a step/end listener" `/goal` pattern. + * channel, so the continued turn's next step sees it). Steering is not a + * `context/message`, so raw context envelopes and durable context metadata are + * deliberately absent. This is the typed twin of the existing "steer from a + * step/end listener" `/goal` pattern. */ export type ContinuationDecision = | { action: 'stop' } - | { action: 'continue'; reason?: HookContext } + | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } /** * The terminal subset of {@link ContinuationDecision}. A listener on @@ -453,7 +455,7 @@ declare module 'cordis' { /** * Waterfall: decide what happens to ONE drained queued message before it * becomes a `user/message` — allow (optionally rewriting the prompt bytes or - * attaching `additionalContext`) or block it. Fires inside the already-open + * attaching `additionalContexts`) or block it. Fires inside the already-open * turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook. * Call `next()` to delegate to the default (allow unchanged), or return a * {@link PromptDecision} without calling `next()` to short-circuit. @@ -475,7 +477,7 @@ declare module 'cordis' { * ALL a listener shapes here: every request is a pure function of the * session log (the reconstructability RFC), so model-visible content * flows through the log channels — `inject()`, steering, prompt-submit - * `additionalContext`, prompt sections via `system-prompt/assemble`, or + * `additionalContexts`, prompt sections via `system-prompt/assemble`, or * the header-logged session prefix via {@link agent/session-prefix} * — never through request mutation, and the loop records whatever config * the request actually uses as a `request/header*` event before dispatch. @@ -525,7 +527,7 @@ declare module 'cordis' { * record, so the request stays reconstructable from the log. Content * that CHANGES mid-session belongs in the append-only history channels * instead — `agent.inject()`, a `tools/post-execute` decision's - * `additionalContext`, prompt-submit `additionalContext` — each a + * `additionalContexts`, prompt-submit `additionalContexts` — each a * durable `context/message` paid once and prefix-cached thereafter. * * The seed is a frozen empty list; a contributing listener returns a NEW diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 26524fad24..545c4c96e7 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -12,7 +12,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) ## 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. +- **`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. `opts.signal` is checked before and after local resolution, while a remote sibling backend may use it to abort its round-trip. 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. - **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`. diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 7f3e28625e..456f2e60e8 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -107,8 +107,10 @@ export class LocalFileSystem extends FileSystem { } } - override async resolve(path: string, opts?: { cwd?: string }): Promise { + override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise { + if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED') const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path) + if (opts?.signal?.aborted) throw new FsError('resolve aborted', 'FS_ABORTED') return { targetKey: local.targetKey, displayPath: local.displayPath } } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 22dc7a708b..ba3daad488 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -73,6 +73,18 @@ describe('resolve', () => { const target = await fs.resolve(join(dir, 'abs.txt'), { cwd: '/nonexistent-base' }) expect(await fs.readText(target)).toBe('absolute') }) + + it('honors a pre-aborted signal', async () => { + await expect(fs.resolve('a.txt', { signal: AbortSignal.abort() })).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) + + it('honors a signal aborted while resolution is in flight', async () => { + const controller = new AbortController() + const pending = fs.resolve('a.txt', { signal: controller.signal }) + controller.abort() + + await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) }) describe('stat', () => { diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 3c323776fa..fa47b5beec 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -19,7 +19,7 @@ A backend subclasses `FileSystem` and implements eight primitives. | Member | Semantics | |---|---| -| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (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`. | +| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (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), while `opts.signal` aborts a backend round-trip. 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. | | `lstat(path, opts?, signal?)` | Return `FsPathInfo` metadata without following the final path component when it is a symlink. This is path-shaped so consumers can reject repository-owned symlinks before `resolve` follows them into a target. | | `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`). | diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index a2c3100e3e..36c26473c9 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -188,16 +188,17 @@ export abstract class FileSystem extends Service { * * `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 + * local backend uses its configured `cwd`). `opts.signal` aborts a backend + * round-trip. The CALLER supplies these — 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. * @param path - the path to resolve; relative paths resolve against `opts.cwd`. - * @param opts - `cwd` overrides the backend's default base for relative paths. + * @param opts - optional cwd override and cancellation signal. * @returns the stable target; the same file yields the same `targetKey`. */ - abstract resolve(path: string, opts?: { cwd?: string }): Promise + abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise /** * Return target metadata, or `undefined` when the target does not exist. diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index fedd1ff7a2..d23a4cb3f5 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -34,7 +34,7 @@ Field names are snake_case to match Claude Code and existing harness tool schema ## 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: +The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd, signal })` — 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`, and forwarding tool cancellation through resolution (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.) diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 220c850d69..1f7589c38c 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -83,7 +83,10 @@ export function applyEditTool(ctx: Context): void { async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { const input = parseEditArgs(args) const cwd = sessionCwd(exec) - const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined) + const target = await ctx.fs.resolve(input.filePath, { + ...cwd !== undefined ? { cwd } : {}, + ...exec.signal !== undefined ? { signal: exec.signal } : {}, + }) // 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. diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 039d8742e9..ed7642535c 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -95,7 +95,10 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { async execute(args, exec): Promise { const input = parseReadArgs(args, caps.limit) const cwd = sessionCwd(exec) - const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined) + const target = await ctx.fs.resolve(input.filePath, { + ...cwd !== undefined ? { cwd } : {}, + ...exec.signal !== undefined ? { signal: exec.signal } : {}, + }) // 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 diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index fd4eec45f3..46c24dc7a3 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -68,7 +68,10 @@ export function applyWriteTool(ctx: Context): void { async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { const input = parseWriteArgs(args) const cwd = sessionCwd(exec) - const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined) + const target = await ctx.fs.resolve(input.filePath, { + ...cwd !== undefined ? { cwd } : {}, + ...exec.signal !== undefined ? { signal: exec.signal } : {}, + }) // 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) diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index c1a60eb198..364fdd3459 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -35,7 +35,7 @@ The hooks **themselves** run in the agent's session workspace: for the agent-sco | CC hook | Harness seam | Mapping | |---|---|---| | `SessionStart` | `agent/session-start` (emit) | additionalContext → `agent.inject()` into the new session (cannot block) | -| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `deny` → `PromptDecision.block`; additionalContext-only → delegate via `next()` then fold context onto the downstream decision (a later listener can still block/rewrite) | +| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `deny` → `PromptDecision.block`; additionalContext-only → delegate via `next()` then prepend a separately sourced context to downstream `additionalContexts` (a later listener can still block/rewrite) | | `PreToolUse` | `tools/pre-execute` (waterfall) | `deny` → `PreToolDecision.deny`; `ask` → `PreToolDecision.ask` | | `PostToolUse` | `tools/post-execute` (waterfall) | `deny` → `block` with feedback; additionalContext-only → delegate via `next()` then prepend a separately sourced context to the downstream decision; Code Mode defers sub-call contexts until the outer `run_code` result | | `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue`, feeding its reason as next-step steering | diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 240761e9ff..a65a844afd 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -221,22 +221,7 @@ export function apply(ctx: Context, config: Config): void { return { content, source: PLUGIN_SOURCE } } - /** - * Concatenate this bridge's prompt {@link HookContext} with a downstream - * prompt listener's optional one, so folding additionalContext drops neither. - * The merged block - * carries a single `source` — this bridge's — because a `HookContext` holds one - * `MessageSource` and the seam cannot represent mixed provenance; the rendered - * `context/message` only distinguishes by `source.kind` ('plugin'), so a - * downstream plugin's text is still correctly framed as plugin context, not a - * user prompt. - */ - function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext { - if (!theirs) return ours - return { content: [...ours.content, ...theirs.content], source: ours.source } - } - - /** Prepend one post-tool context without flattening downstream provenance. */ + /** Prepend one context without flattening downstream provenance or metadata. */ function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] { return [ours, ...theirs ?? []] } @@ -279,7 +264,7 @@ export function apply(ctx: Context, config: Config): void { return { kind: 'allow', ...downstream.content !== undefined ? { content: downstream.content } : {}, - additionalContext: concatContext(ours, downstream.additionalContext), + additionalContexts: prependContext(ours, downstream.additionalContexts), } }) diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index 8675b2511a..222de0a3f1 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -479,9 +479,9 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' }) }) - it('folds the bridge additionalContext WITH a downstream listener that also adds context', async () => { + it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => { // Both the bridge hook and a later prompt-submit listener attach context; the - // request must see BOTH (concatContext keeps the downstream one too). + // request must see both as separately sourced durable events. const d = dir() const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) @@ -490,7 +490,12 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => ctx.on('agent/prompt-submit', async () => ({ kind: 'allow' as const, content: [{ type: 'text' as const, text: 'rewritten-prompt' }], - additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } }, + additionalContexts: [{ + content: [{ type: 'text' as const, text: 'from-downstream' }], + source: { kind: 'plugin' as const, plugin: 'policy' }, + envelope: 'raw' as const, + meta: { owner: 'policy' }, + }], })) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) @@ -502,6 +507,13 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => // the original prompt was replaced by the downstream rewrite const userMsg = events(agent).find(e => e.type === 'user/message') expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true) + const contexts = events(agent).filter(event => event.type === 'context/message') + expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + { kind: 'plugin', plugin: 'hooks-claude' }, + { kind: 'plugin', plugin: 'policy' }, + ]) + expect(contexts[1]?.type === 'context/message' && contexts[1].data.envelope).toBe('raw') + expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => { diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index 7459099157..7a9a153eff 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -41,7 +41,7 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped | Codex hook | Harness seam | Mapping | |---|---|---| | `SessionStart` | `agent/session-start` (emit) | a plain-stdout hook's output → additionalContext → `agent.inject()` | -| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `block` (exit 2) → `PromptDecision.block`; additionalContext-only → delegate via `next()` then fold context onto the downstream decision | +| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `block` (exit 2) → `PromptDecision.block`; additionalContext-only → delegate via `next()` then prepend a separately sourced context to downstream `additionalContexts` | | `PreToolUse` | `tools/pre-execute` (waterfall) | `block` → `PreToolDecision.deny` (no `allow`/`ask`) | | `PostToolUse` | `tools/post-execute` (waterfall) | `block` → `block` with feedback; additionalContext-only → delegate via `next()` then prepend a separately sourced context to the downstream decision; Code Mode defers sub-call contexts until the outer `run_code` result | | `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue` with the reason as next-step steering | diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 383966e537..29ca4de3b5 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -176,21 +176,7 @@ export function apply(ctx: Context, config: Config): void { return { content, source: PLUGIN_SOURCE } } - /** - * Concatenate this bridge's prompt {@link HookContext} with a downstream - * prompt listener's optional one, so folding additionalContext drops neither. - * The merged block - * carries a single `source` — this bridge's — because a `HookContext` holds one - * `MessageSource` and the seam cannot represent mixed provenance; the rendered - * `context/message` only distinguishes by `source.kind` ('plugin'), so a - * downstream plugin's text is still correctly framed as plugin context. - */ - function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext { - if (!theirs) return ours - return { content: [...ours.content, ...theirs.content], source: ours.source } - } - - /** Prepend one post-tool context without flattening downstream provenance. */ + /** Prepend one context without flattening downstream provenance or metadata. */ function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] { return [ours, ...theirs ?? []] } @@ -222,7 +208,7 @@ export function apply(ctx: Context, config: Config): void { return { kind: 'allow', ...downstream.content !== undefined ? { content: downstream.content } : {}, - additionalContext: concatContext(ours, downstream.additionalContext), + additionalContexts: prependContext(ours, downstream.additionalContexts), } }) diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index c7120b56f4..8534d222a1 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -86,7 +86,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { expect(te?.type === 'turn/end' && te.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' }) }) - it('folds the bridge additionalContext WITH a downstream listener that also adds context', async () => { + it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => { const d = dir() hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) @@ -94,7 +94,12 @@ describe('hooks-codex coverage — decision mapping paths', () => { ctx.on('agent/prompt-submit', async () => ({ kind: 'allow' as const, content: [{ type: 'text' as const, text: 'rewritten-prompt' }], - additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } }, + additionalContexts: [{ + content: [{ type: 'text' as const, text: 'from-downstream' }], + source: { kind: 'plugin' as const, plugin: 'policy' }, + envelope: 'raw' as const, + meta: { owner: 'policy' }, + }], })) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) @@ -102,6 +107,13 @@ describe('hooks-codex coverage — decision mapping paths', () => { expect(req).toContain('from-bridge') expect(req).toContain('from-downstream') expect(req).toContain('rewritten-prompt') + const contexts = events(agent).filter(event => event.type === 'context/message') + expect(contexts.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + { kind: 'plugin', plugin: 'hooks-codex' }, + { kind: 'plugin', plugin: 'policy' }, + ]) + expect(contexts[1]?.type === 'context/message' && contexts[1].data.envelope).toBe('raw') + expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => { diff --git a/packages/prompt/workspace-context/README.md b/packages/prompt/workspace-context/README.md index a69e117a67..61384a4d80 100644 --- a/packages/prompt/workspace-context/README.md +++ b/packages/prompt/workspace-context/README.md @@ -8,7 +8,7 @@ The baseline is composed once per agent-loop instance on `agent/session-prefix`. The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. A new file is attached through the result's `additionalContexts`; a changed file or candidate switch appends a replacement; a missing final candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable. -Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It calls `ctx.fs.lstat` before resolving a candidate, rejecting a final-component symlink instead of following repository-owned links across the trust boundary. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted. +Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It calls `ctx.fs.lstat` before resolving a candidate, rejecting a final-component symlink instead of following repository-owned links across the trust boundary. Prefix cancellation and dynamic tool cancellation propagate through resolution, metadata probes, and streaming reads. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted. ## Prompt Shape @@ -46,7 +46,7 @@ The core `context/message` envelope is disabled for these messages because the p ## State And Refresh -Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, previousPath?, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context returned by `tools/post-execute` but not yet appended by the loop. +Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, previousPath?, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. An unchanged path and SHA-1 content digest is not injected again. Resume works because visible metadata is persisted in the session log. Compaction re-arms a scope after its context event leaves the visible surface. A removal is a tombstone, so a later candidate reappearance is loaded again. Only changes actually rendered within the byte budget enter metadata and pending state; an omitted change remains eligible for a later touch. @@ -59,19 +59,20 @@ export interface Config { dshHome?: string projectRootMarkers?: string[] maxBytes: number + maxSourceBytes?: number instructionFileCandidates?: string[] } ``` -`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. Candidate entries must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored. +`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `maxSourceBytes` limits each source instruction file before rendering and defaults to 1 MiB. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. Candidate entries must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored. -The user-global file is always `$DSH_HOME/AGENTS.md`; the candidate list only controls project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite byte budget disables both baseline and dynamic loading. +The user-global file is always `$DSH_HOME/AGENTS.md`; the candidate list only controls project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite render budget disables both baseline and dynamic loading; configured `maxSourceBytes` must be a positive integer. -## Budgeting And Cache +## Budgeting And Bounded Reads Rendering preserves the most specific instruction files first. It drops whole broader files before truncating the most-specific file and emits a visible `Workspace instruction budget ...` notice naming omitted and truncated paths. The rendered bytes never exceed `maxBytes`. -Each discovered candidate is read and identified by normalized absolute path, the provider's opaque version, and a SHA-1 content digest. Comparing the digest after reading prevents a same-version, same-size rewrite from returning stale cached text. Discovery carries the provider version into reading so one pass does not stat the same file twice. Cache identity is separate from loaded-state identity: persisted structured metadata, not cached prose, controls duplicate suppression. +Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored without falling through to a lower-priority same-directory candidate; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide prose cache: every reconciliation observes current content, computes its SHA-1 only after the bounded read, and relies on persisted structured metadata for duplicate suppression. ## Non-goals diff --git a/packages/prompt/workspace-context/src/config.ts b/packages/prompt/workspace-context/src/config.ts index 6657e0446d..c4bdd663c7 100644 --- a/packages/prompt/workspace-context/src/config.ts +++ b/packages/prompt/workspace-context/src/config.ts @@ -9,6 +9,7 @@ import { resolveDshHome } from '@deepseek-ai/dsh-paths' const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const +const DEFAULT_MAX_SOURCE_BYTES = 1_048_576 const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..']) /** User-facing workspace instruction loader configuration. */ @@ -19,6 +20,8 @@ export interface Config { projectRootMarkers?: string[] /** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */ maxBytes: number + /** Maximum UTF-8 bytes read from one instruction file; larger files are ignored. */ + maxSourceBytes?: number /** Ordered same-directory project candidates; the first existing regular file wins in each scope. */ instructionFileCandidates?: string[] } @@ -27,6 +30,7 @@ export const Config: z = z.object({ dshHome: z.string(), projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]), maxBytes: z.number().required(), + maxSourceBytes: z.number().step(1).min(1).default(DEFAULT_MAX_SOURCE_BYTES), instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]), }) @@ -40,6 +44,7 @@ export interface ResolvedDiscoveryConfig { /** Normalized configuration used by discovery and reconciliation. */ export interface ResolvedConfig extends ResolvedDiscoveryConfig { maxBytes: number + maxSourceBytes: number } /** @@ -51,6 +56,7 @@ export function resolveConfig(config: Config): ResolvedConfig { return { ...resolveDiscoveryConfig(config), maxBytes: config.maxBytes, + maxSourceBytes: config.maxSourceBytes ?? DEFAULT_MAX_SOURCE_BYTES, } } diff --git a/packages/prompt/workspace-context/src/digest.ts b/packages/prompt/workspace-context/src/digest.ts index 36cb646b0d..4568371277 100644 --- a/packages/prompt/workspace-context/src/digest.ts +++ b/packages/prompt/workspace-context/src/digest.ts @@ -1,5 +1,5 @@ /** - * Content identity for workspace instruction caching and duplicate suppression. + * Content identity for workspace instruction duplicate suppression. * * @module @deepseek-ai/dsh-workspace-context/digest */ diff --git a/packages/prompt/workspace-context/src/files.ts b/packages/prompt/workspace-context/src/files.ts index 42eeb0ba2b..5ba24a6c57 100644 --- a/packages/prompt/workspace-context/src/files.ts +++ b/packages/prompt/workspace-context/src/files.ts @@ -1,15 +1,15 @@ /** - * Instruction-file discovery, provider reads, and content-aware caching. + * Instruction-file discovery and bounded, abort-aware provider reads. * * @module @deepseek-ai/dsh-workspace-context/files */ -import { lstat, readFile, stat } from 'node:fs/promises' +import { createReadStream } from 'node:fs' +import { lstat, stat } from 'node:fs/promises' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import type { FileSystem, FsInfo, FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs' import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths' import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts' -import { instructionContentSha1 } from './digest.ts' import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts' /** An instruction candidate identified by absolute and model-facing paths. */ @@ -23,33 +23,22 @@ export interface LoadedInstructionFile extends InstructionFile { content: string } -interface FileSignature { - version: string -} - -interface CachedContent extends FileSignature { - sha1: string - content: string -} - interface DiscoveredInstructionFile extends InstructionFile { - signature: FileSignature target?: FsTarget + size?: number } -/** Provider-version and SHA-1 keyed content cache shared across plugin hooks. */ -export type InstructionContentCache = Map - interface DiscoverOptions { cwd: string dshHome?: string projectRootMarkers?: string[] instructionFileCandidates?: string[] + signal?: AbortSignal } interface LoadOptions extends DiscoverOptions { maxBytes: number - cache?: InstructionContentCache + maxSourceBytes?: number } /** Rendered baseline plus the files that survived byte budgeting. */ @@ -64,12 +53,19 @@ export type ScopeInstructionProbe = | { kind: 'absent' } | { kind: 'unavailable' } -async function nodeStatFile(path: string): Promise { +function signalOptions(signal?: AbortSignal): { signal: AbortSignal } | undefined { + return signal === undefined ? undefined : { signal } +} + +async function nodeStatFile(path: string, signal?: AbortSignal): Promise<{ size: number } | undefined> { try { + signal?.throwIfAborted() const info = await lstat(path) + signal?.throwIfAborted() if (!info.isFile()) return undefined - return { version: String(info.mtimeMs) } + return { size: info.size } } catch { + signal?.throwIfAborted() // Candidates can disappear while discovery is in progress. return undefined } @@ -78,15 +74,17 @@ async function nodeStatFile(path: string): Promise { async function fsStatFile( path: string, fileSystem: FileSystem, -): Promise { + signal?: AbortSignal, +): Promise<{ target: FsTarget; size?: number } | undefined> { try { - const pathInfo = await fileSystem.lstat(path) + const pathInfo = await fileSystem.lstat(path, undefined, signal) if (pathInfo?.type !== 'file') return undefined - const target = await fileSystem.resolve(path) - const info = await fileSystem.stat(target) + const target = await fileSystem.resolve(path, signalOptions(signal)) + const info = await fileSystem.stat(target, signal) if (info?.type !== 'file') return undefined - return { version: info.version, target } + return { target, ...info.size === undefined ? {} : { size: info.size } } } catch { + signal?.throwIfAborted() // Provider absence and discovery races are both non-fatal. return undefined } @@ -95,23 +93,28 @@ async function fsStatFile( async function statFile( path: string, fileSystem?: FileSystem, -): Promise<(DiscoveredInstructionFile['signature'] & { target?: FsTarget }) | undefined> { - return fileSystem === undefined ? nodeStatFile(path) : fsStatFile(path, fileSystem) + signal?: AbortSignal, +): Promise<{ target?: FsTarget; size?: number } | undefined> { + return fileSystem === undefined ? nodeStatFile(path, signal) : fsStatFile(path, fileSystem, signal) } -async function existsAsMarker(path: string, fileSystem?: FileSystem): Promise { +async function existsAsMarker(path: string, fileSystem?: FileSystem, signal?: AbortSignal): Promise { if (fileSystem !== undefined) { try { - const target = await fileSystem.resolve(path) - return await fileSystem.stat(target) !== undefined + const target = await fileSystem.resolve(path, signalOptions(signal)) + return await fileSystem.stat(target, signal) !== undefined } catch { + signal?.throwIfAborted() return false } } try { + signal?.throwIfAborted() await stat(path) + signal?.throwIfAborted() return true } catch { + signal?.throwIfAborted() return false } } @@ -121,17 +124,19 @@ async function existsAsMarker(path: string, fileSystem?: FileSystem): Promise { let current = resolve(cwd) for (;;) { for (const marker of markers) { - if (await existsAsMarker(join(current, marker), fileSystem)) return current + if (await existsAsMarker(join(current, marker), fileSystem, signal)) return current } const parent = dirname(current) if (parent === current) return resolve(cwd) @@ -190,17 +195,16 @@ async function firstExistingInstructionFile( root: string, instructionFileCandidates: readonly string[], fileSystem?: FileSystem, + signal?: AbortSignal, ): Promise { for (const candidate of instructionFileCandidates) { const path = join(dir, candidate) - const fileSignature = await statFile(path, fileSystem) - if (fileSignature !== undefined) { - const { target, ...signature } = fileSignature + const fileInfo = await statFile(path, fileSystem, signal) + if (fileInfo !== undefined) { return { absolutePath: path, displayPath: relativeDisplay(root, path), - signature, - ...target === undefined ? {} : { target }, + ...fileInfo, } } } @@ -221,21 +225,19 @@ async function discoverInstructionFiles( } const userGlobal = join(config.dshHome, 'AGENTS.md') - const userGlobalSignature = await statFile(userGlobal, fileSystem) - if (userGlobalSignature !== undefined) { - const { target, ...signature } = userGlobalSignature + const userGlobalInfo = await statFile(userGlobal, fileSystem, options.signal) + if (userGlobalInfo !== undefined) { addFile({ absolutePath: userGlobal, displayPath: userGlobalDisplayPath(config.dshHome), - signature, - ...target === undefined ? {} : { target }, + ...userGlobalInfo, }) } const cwd = resolve(options.cwd) - const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem) + const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal) for (const dir of ancestorChain(projectRoot, cwd)) { - const file = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, fileSystem) + const file = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, fileSystem, options.signal) if (file !== undefined) addFile(file) } return files @@ -250,23 +252,35 @@ export async function discoverBaselineInstructionFiles(options: DiscoverOptions) return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath })) } -async function readCached( +async function* nodeTextChunks(path: string, signal?: AbortSignal): AsyncIterable { + const stream = createReadStream(path, { encoding: 'utf8', signal }) + for await (const chunk of stream) yield String(chunk) +} + +async function readBounded( file: DiscoveredInstructionFile, - cache: InstructionContentCache, + maxSourceBytes: number, fileSystem?: FileSystem, + signal?: AbortSignal, ): Promise { - const path = file.absolutePath - const { signature } = file + signal?.throwIfAborted() + if (file.size !== undefined && file.size > maxSourceBytes) return undefined try { - const content = fileSystem === undefined || file.target === undefined - ? await readFile(path, 'utf8') - : await fileSystem.readText(file.target) - const sha1 = instructionContentSha1(content) - const cached = cache.get(path) - if (cached !== undefined && cached.version === signature.version && cached.sha1 === sha1) return cached.content - cache.set(path, { ...signature, sha1, content }) - return content + const chunks = fileSystem === undefined || file.target === undefined + ? nodeTextChunks(file.absolutePath, signal) + : await fileSystem.streamText(file.target, signal) + const parts: string[] = [] + let bytes = 0 + for await (const chunk of chunks) { + signal?.throwIfAborted() + bytes += Buffer.byteLength(chunk, 'utf8') + if (bytes > maxSourceBytes) return undefined + parts.push(chunk) + } + signal?.throwIfAborted() + return parts.join('') } catch { + signal?.throwIfAborted() // A file may disappear or become unreadable after its metadata probe. return undefined } @@ -274,7 +288,7 @@ async function readCached( /** * Discover, read, and render the baseline instruction chain. - * @param options - discovery, byte-budget, and optional cache configuration. + * @param options - discovery, source-size, byte-budget, and cancellation configuration. * @param fileSystem - optional provider used instead of host filesystem reads. * @returns rendered baseline context, or undefined when nothing can be loaded. */ @@ -287,7 +301,7 @@ export async function loadBaselineInstructions( /** * Load a baseline together with the files retained after rendering. - * @param options - discovery, byte-budget, and optional cache configuration. + * @param options - discovery, source-size, byte-budget, and cancellation configuration. * @param fileSystem - optional provider used instead of host filesystem reads. * @returns rendered context and retained files, or undefined when empty or disabled. */ @@ -297,11 +311,11 @@ export async function loadBaselineInstructionSet( ): Promise { const config = resolveConfig(options) if (config.maxBytes <= 0 || !Number.isFinite(config.maxBytes)) return undefined - const cache = options.cache ?? new Map() + if (config.maxSourceBytes <= 0 || !Number.isFinite(config.maxSourceBytes)) return undefined const discovered = await discoverInstructionFiles(options, fileSystem) const loaded: LoadedInstructionFile[] = [] for (const file of discovered) { - const content = await readCached(file, cache, fileSystem) + const content = await readBounded(file, config.maxSourceBytes, fileSystem, options.signal) if (content !== undefined) loaded.push({ absolutePath: file.absolutePath, displayPath: file.displayPath, content }) } if (loaded.length === 0) return undefined @@ -315,16 +329,16 @@ export async function loadBaselineInstructionSet( * @param scope - `user-global`, `.`, or a project-relative directory. * @param projectRoot - project root used to resolve and display project scopes. * @param resolved - normalized plugin configuration. - * @param cache - shared content cache. * @param fileSystem - provider used for no-follow probing and reading. + * @param signal - cancellation for provider probes and streaming. * @returns present content, confirmed absence, or temporary unavailability. */ export async function loadScopeInstruction( scope: string, projectRoot: string, resolved: ResolvedConfig, - cache: InstructionContentCache, fileSystem: FileSystem, + signal?: AbortSignal, ): Promise { const dir = scope === 'user-global' ? resolved.dshHome @@ -334,27 +348,29 @@ export async function loadScopeInstruction( const absolutePath = join(dir, candidate) let pathInfo: FsPathInfo | undefined try { - pathInfo = await fileSystem.lstat(absolutePath) + pathInfo = await fileSystem.lstat(absolutePath, undefined, signal) } catch { + signal?.throwIfAborted() return { kind: 'unavailable' } } if (pathInfo === undefined || pathInfo.type !== 'file') continue let target: FsTarget let info: FsInfo | undefined try { - target = await fileSystem.resolve(absolutePath) - info = await fileSystem.stat(target) + target = await fileSystem.resolve(absolutePath, signalOptions(signal)) + info = await fileSystem.stat(target, signal) } catch { + signal?.throwIfAborted() return { kind: 'unavailable' } } if (info?.type !== 'file') return { kind: 'unavailable' } const discovered: DiscoveredInstructionFile = { absolutePath, displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath), - signature: { version: info.version }, target, + ...info.size === undefined ? {} : { size: info.size }, } - const content = await readCached(discovered, cache, fileSystem) + const content = await readBounded(discovered, resolved.maxSourceBytes, fileSystem, signal) if (content === undefined) return { kind: 'unavailable' } return { kind: 'present', file: { absolutePath, displayPath: discovered.displayPath, content } } } diff --git a/packages/prompt/workspace-context/src/index.ts b/packages/prompt/workspace-context/src/index.ts index f92f73dccb..9b6f8bf826 100644 --- a/packages/prompt/workspace-context/src/index.ts +++ b/packages/prompt/workspace-context/src/index.ts @@ -12,17 +12,16 @@ import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Message } from '@deepseek-ai/dsh-llm' -import type { PostToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { Config, resolveConfig, type ResolvedConfig } from './config.ts' -import { - loadBaselineInstructionSet, - type InstructionContentCache, -} from './files.ts' +import { loadBaselineInstructionSet } from './files.ts' import { baselineInstructionChanges, + commitPendingInstructionContexts, dynamicInstructionContext, name, reconcileInstructionContext, + rollbackPendingInstructionChanges, workspaceContextMessage, type PendingInstructionChange, } from './state.ts' @@ -34,7 +33,6 @@ export { loadBaselineInstructions, } from './files.ts' export type { - InstructionContentCache, InstructionFile, LoadedInstructionFile, } from './files.ts' @@ -43,11 +41,11 @@ export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts export function apply(ctx: Context, config: Config): void { const resolved: ResolvedConfig = resolveConfig(config) - const cache: InstructionContentCache = new Map() const pendingNestedChanges = new WeakMap>() const baselineInstructionStates = new WeakMap>() + const pendingByParent = new Map() - ctx.on('agent/session-prefix', async (agent: Agent, _prefix, _signal, next): Promise => { + ctx.on('agent/session-prefix', async (agent: Agent, _prefix, signal, next): Promise => { const rest = await next() if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) return rest const fileSystem = ctx.get('fs') @@ -59,19 +57,19 @@ export function apply(ctx: Context, config: Config): void { dshHome: resolved.dshHome, projectRootMarkers: resolved.projectRootMarkers, maxBytes: resolved.maxBytes, + maxSourceBytes: resolved.maxSourceBytes, instructionFileCandidates: resolved.instructionFileCandidates, - cache, + signal, }, fileSystem) baselineInstructionStates.set(agent.session, baselineInstructionChanges(instructions?.included ?? [])) const update = await reconcileInstructionContext( agent, resolved, - cache, pendingNestedChanges, baselineInstructionStates, fileSystem, - { includeBaselineScopes: false }, + { includeBaselineScopes: false, signal }, ) if (update !== undefined) { agent.inject(update.content, { @@ -104,7 +102,6 @@ export function apply(ctx: Context, config: Config): void { exec, result, resolved, - cache, pendingNestedChanges, baselineInstructionStates, fileSystem, @@ -116,4 +113,28 @@ export function apply(ctx: Context, config: Config): void { additionalContexts: [context, ...downstream.additionalContexts ?? []], } }) + + ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => { + if (exec.parent !== undefined) { + if (exec.agent === undefined) return + // Child contexts participate in duplicate suppression within one composite + // run, but remain provisional until the parent reaches its final policy. + const changes = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges) + if (changes.length === 0) return + const staged = pendingByParent.get(exec.parent) + if (staged === undefined) pendingByParent.set(exec.parent, { agent: exec.agent, changes }) + else staged.changes.push(...changes) + return + } + + // The parent result is authoritative: remove every provisional child change, + // then commit only contexts that survived outer post-execute policy. + const staged = pendingByParent.get(exec.token) + if (staged !== undefined) { + pendingByParent.delete(exec.token) + rollbackPendingInstructionChanges(staged.agent, staged.changes, pendingNestedChanges) + } + if (exec.agent === undefined) return + commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges) + }) } diff --git a/packages/prompt/workspace-context/src/state.ts b/packages/prompt/workspace-context/src/state.ts index 6754b3a570..9a3e87d7f7 100644 --- a/packages/prompt/workspace-context/src/state.ts +++ b/packages/prompt/workspace-context/src/state.ts @@ -17,7 +17,6 @@ import { findProjectRoot, loadScopeInstruction, relativeDisplay, - type InstructionContentCache, type LoadedInstructionFile, } from './files.ts' import { @@ -157,6 +156,56 @@ function pendingChangesFor( return pending } +/** + * Commit only workspace contexts that survived the complete tool pipeline. + * The observe-only `tools/result` notification calls this before the loop can + * append the returned contexts, closing that short pending window without + * trusting an intermediate post-execute decision. + * @param agent - session that will receive the final result contexts. + * @param contexts - immutable contexts on the authoritative top-level result. + * @param pendingBySession - per-session pending transition maps. + * @returns transitions committed into the short pending window. + */ +export function commitPendingInstructionContexts( + agent: Agent, + contexts: readonly HookContext[] | undefined, + pendingBySession: WeakMap>, +): WorkspaceInstructionChange[] { + const committed: WorkspaceInstructionChange[] = [] + for (const context of contexts ?? []) { + if (!isWorkspaceContextSource(context.source)) continue + const changes = workspaceInstructionChanges(context.meta) + if (changes.length === 0) continue + const pending = pendingChangesFor(agent.session, pendingBySession) + for (const change of changes) { + pending.set(change.scope, { change, afterSeq: agent.session.seq }) + committed.push(change) + } + } + return committed +} + +/** + * Roll back parent-token state when an enclosing tool result discards deferred + * contexts. A newer transition for the same scope is left intact. + * @param agent - session whose pending state was staged. + * @param changes - exact staged transitions to remove when still current. + * @param pendingBySession - per-session pending transition maps. + */ +export function rollbackPendingInstructionChanges( + agent: Agent, + changes: readonly WorkspaceInstructionChange[], + pendingBySession: WeakMap>, +): void { + const pending = pendingBySession.get(agent.session) + if (pending === undefined) return + for (const change of changes) { + const current = pending.get(change.scope) + if (current !== undefined && sameInstructionChange(current.change, change)) pending.delete(change.scope) + } + if (pending.size === 0) pendingBySession.delete(agent.session) +} + function relativeScope(projectRoot: string, dir: string): string { const scope = relativeDisplay(projectRoot, dir) return scope.length === 0 ? '.' : scope @@ -166,7 +215,6 @@ function relativeScope(projectRoot: string, dir: string): string { * Compare visible/pending state with provider-visible files and render transitions. * @param agent - session owner whose visible surface supplies durable state. * @param resolved - normalized plugin configuration. - * @param cache - shared provider-version and content-digest cache. * @param pendingBySession - short pending window before returned context is logged. * @param baselineBySession - frozen baseline comparison state per session. * @param fileSystem - provider used for current file probes. @@ -176,11 +224,10 @@ function relativeScope(projectRoot: string, dir: string): string { export async function reconcileInstructionContext( agent: Agent, resolved: ResolvedConfig, - cache: InstructionContentCache, pendingBySession: WeakMap>, baselineBySession: WeakMap>, fileSystem: FileSystem, - options: { touchedPath?: string; includeBaselineScopes: boolean }, + options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal }, ): Promise { const session = agent.session const pending = pendingChangesFor(session, pendingBySession) @@ -189,7 +236,7 @@ export async function reconcileInstructionContext( for (const [scope, change] of visible) effective.set(scope, change) /* v8 ignore next -- normal agents carry an absolute session cwd. */ const cwd = session.header.cwd ?? process.cwd() - const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem) + const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal) const scopes = new Set() if (options.includeBaselineScopes) { scopes.add('user-global') @@ -204,7 +251,7 @@ export async function reconcileInstructionContext( const unavailable = new Set() const seenAbsolutePaths = new Set() for (const scope of scopes) { - const probe = await loadScopeInstruction(scope, projectRoot, resolved, cache, fileSystem) + const probe = await loadScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal) if (probe.kind === 'unavailable') { unavailable.add(scope) continue @@ -250,7 +297,6 @@ export async function reconcileInstructionContext( if (items.length === 0) return undefined const rendered = renderInstructionChanges(items, resolved.maxBytes) if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined - for (const change of rendered.changes) pending.set(change.scope, { change, afterSeq: session.seq }) return workspaceContextHook(rendered.text, rendered.changes) } @@ -260,7 +306,6 @@ export async function reconcileInstructionContext( * @param exec - completed tool execution descriptor. * @param result - original tool result before post-execute decisions. * @param resolved - normalized plugin configuration. - * @param cache - shared provider-version and content-digest cache. * @param pendingNestedChanges - per-session pending transition maps. * @param baselineInstructionStates - retained baseline comparison state. * @param fileSystem - provider used for current file probes. @@ -271,7 +316,6 @@ export async function dynamicInstructionContext( exec: ToolExecution, result: ToolExecutionResult, resolved: ResolvedConfig, - cache: InstructionContentCache, pendingNestedChanges: WeakMap>, baselineInstructionStates: WeakMap>, fileSystem: FileSystem, @@ -280,7 +324,11 @@ export async function dynamicInstructionContext( const touchedPath = filePathFromExecution(exec) if (touchedPath === undefined) return undefined return reconcileInstructionContext( - agent, resolved, cache, pendingNestedChanges, baselineInstructionStates, fileSystem, - { touchedPath, includeBaselineScopes: baselineInstructionStates.has(agent.session) }, + agent, resolved, pendingNestedChanges, baselineInstructionStates, fileSystem, + { + touchedPath, + includeBaselineScopes: baselineInstructionStates.has(agent.session), + ...exec.signal === undefined ? {} : { signal: exec.signal }, + }, ) } diff --git a/packages/prompt/workspace-context/tests/workspace-context.spec.ts b/packages/prompt/workspace-context/tests/workspace-context.spec.ts index 86b57f4d90..321da53b84 100644 --- a/packages/prompt/workspace-context/tests/workspace-context.spec.ts +++ b/packages/prompt/workspace-context/tests/workspace-context.spec.ts @@ -22,15 +22,19 @@ import type { } from '@deepseek-ai/dsh-fs' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { discoverBaselineInstructionFiles, loadBaselineInstructions, renderWorkspaceContext, - type InstructionContentCache, } from '@deepseek-ai/dsh-workspace-context' +import { + commitPendingInstructionContexts, + rollbackPendingInstructionChanges, + type PendingInstructionChange, +} from '../src/state.ts' async function tempRepo(): Promise { return mkdtemp(join(tmpdir(), 'dsh-workspace-context-')) @@ -45,14 +49,21 @@ class RecordingFileSystem extends FileSystem { entries = new Map() lstatTypes = new Map() throwOnStat = new Set() + omitSizes = new Set() readTargets: string[] = [] + readTextTargets: string[] = [] + signals: AbortSignal[] = [] - override async resolve(path: string, opts?: { cwd?: string }): Promise { + override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise { + if (opts?.signal !== undefined) this.signals.push(opts.signal) + opts?.signal?.throwIfAborted() const absolute = join(opts?.cwd ?? '/', path) return { targetKey: FsTargetKey(absolute), displayPath: absolute } } - override async stat(target: FsTarget): Promise { + override async stat(target: FsTarget, signal?: AbortSignal): Promise { + if (signal !== undefined) this.signals.push(signal) + signal?.throwIfAborted() if (this.throwOnStat.has(target.targetKey)) throw new Error(`stat failed: ${target.displayPath}`) const entry = this.entries.get(target.targetKey) if (entry === undefined) return undefined @@ -60,15 +71,17 @@ class RecordingFileSystem extends FileSystem { version: FsVersion(`v:${target.targetKey}`), type: entry.type, } - if (entry.content !== undefined) info.size = Buffer.byteLength(entry.content, 'utf8') + if (entry.content !== undefined && !this.omitSizes.has(target.targetKey)) info.size = Buffer.byteLength(entry.content, 'utf8') return info } - override async lstat(path: string, opts?: { cwd?: string }): Promise { - const target = await this.resolve(path, opts) + override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise { + if (signal !== undefined) this.signals.push(signal) + signal?.throwIfAborted() + const target = await this.resolve(path, { ...opts, ...signal === undefined ? {} : { signal } }) const lstatType = this.lstatTypes.get(target.targetKey) if (lstatType !== undefined) return { version: FsVersion(`lstat:${target.targetKey}`), type: lstatType } - const info = await this.stat(target) + const info = await this.stat(target, signal) if (info === undefined) return undefined return { version: info.version, @@ -77,14 +90,24 @@ class RecordingFileSystem extends FileSystem { } } - override async readText(target: FsTarget): Promise { - this.readTargets.push(target.targetKey) + override async readText(target: FsTarget, signal?: AbortSignal): Promise { + if (signal !== undefined) this.signals.push(signal) + signal?.throwIfAborted() + this.readTextTargets.push(target.targetKey) return this.entries.get(target.targetKey)?.content ?? '' } - override async streamText(target: FsTarget): Promise> { - const content = await this.readText(target) - return (async function* () { yield content })() + override async streamText(target: FsTarget, signal?: AbortSignal): Promise> { + if (signal !== undefined) this.signals.push(signal) + signal?.throwIfAborted() + this.readTargets.push(target.targetKey) + const content = this.entries.get(target.targetKey)?.content ?? '' + return (async function* () { + const midpoint = Math.ceil(content.length / 2) + yield content.slice(0, midpoint) + signal?.throwIfAborted() + yield content.slice(midpoint) + })() } override async listDir(_target: FsTarget): Promise { @@ -100,6 +123,24 @@ class RecordingFileSystem extends FileSystem { } } +class BlockingReadFileSystem extends RecordingFileSystem { + readonly started = Promise.withResolvers() + + override async streamText(target: FsTarget, signal?: AbortSignal): Promise> { + if (signal !== undefined) this.signals.push(signal) + this.readTargets.push(target.targetKey) + this.started.resolve(undefined) + return (async function* () { + await new Promise((_resolve, reject) => { + const abortReason = (): Error => signal?.reason instanceof Error ? signal.reason : new Error('aborted') + if (signal?.aborted) { reject(abortReason()); return } + signal?.addEventListener('abort', () => { reject(abortReason()) }, { once: true }) + }) + yield 'unreachable' + })() + } +} + async function mountWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { await ctx.plugin(LocalFileSystem, { cwd: '/' }) return ctx.plugin(workspaceContext, config) @@ -153,6 +194,19 @@ function workspaceContextOf(result: { additionalContexts?: HookContext[] }): Hoo context.source.kind === 'plugin' && context.source.plugin === 'workspace-context') } +function workspaceChangeContext(scope: string, digest: string): HookContext { + return { + content: [{ type: 'text', text: `instructions for ${scope}` }], + source: { kind: 'plugin', plugin: 'workspace-context' }, + envelope: 'raw', + meta: { + kind: 'workspace-instructions', + version: 1, + changes: [{ action: 'set', scope, path: `${scope}/AGENTS.md`, digest }], + }, + } +} + function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: HookContext[] }): number | undefined { let lastSeq: number | undefined for (const context of result.additionalContexts ?? []) { @@ -235,7 +289,7 @@ describe('workspace context instruction discovery', () => { } }) - it('refreshes cached content after a same-version, same-size rewrite', async () => { + it('re-reads content after a same-version, same-size rewrite', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -243,20 +297,19 @@ describe('workspace context instruction discovery', () => { await mkdir(join(root, '.git'), { recursive: true }) await mkdir(cwd, { recursive: true }) - const cache: InstructionContentCache = new Map() - expect(await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache })).toBeUndefined() + expect(await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })).toBeUndefined() const leaf = join(cwd, 'AGENTS.md') await write(leaf, 'first') - const first = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache }) + const first = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 }) expect(first?.text).toContain('first') - const cached = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache }) - expect(cached?.text).toContain('first') + const again = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 }) + expect(again?.text).toContain('first') const before = await stat(leaf) await writeFile(leaf, 'other') await utimes(leaf, before.atime, before.mtime) - const second = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache }) + const second = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 }) expect(second?.text).toContain('other') expect(second?.text).not.toContain('first') } finally { @@ -337,6 +390,10 @@ describe('workspace context instruction discovery', () => { await write(join(root, 'AGENTS.md'), 'repo rule') await expect(loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 0 })).resolves.toBeUndefined() + await expect(loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536, maxSourceBytes: 0 })).resolves.toBeUndefined() + await expect(loadBaselineInstructions({ + cwd: root, dshHome: home, maxBytes: 65536, maxSourceBytes: Infinity, + })).resolves.toBeUndefined() } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -1035,6 +1092,84 @@ describe('workspace context request injection', () => { } }) + it('rejects a provider-sized instruction file before reading content', async () => { + const root = join(await tempRepo(), 'virtual-repo') + const home = join(await tempRepo(), 'virtual-home') + const ctx = new Context() + try { + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'far too large' }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) + + const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) + + expect(prefix).toEqual([]) + expect(fs.readTargets).toEqual([]) + expect(fs.readTextTargets).toEqual([]) + } finally { + await ctx.fiber.dispose() + await rm(dirname(root), { recursive: true, force: true }) + await rm(dirname(home), { recursive: true, force: true }) + } + }) + + it('bounds streamed instruction content when provider size is unavailable', async () => { + const root = join(await tempRepo(), 'virtual-repo') + const home = join(await tempRepo(), 'virtual-home') + const ctx = new Context() + try { + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + const instructionPath = join(root, 'AGENTS.md') + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(instructionPath, { type: 'file', content: 'far too large' }) + fs.omitSizes.add(instructionPath) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) + + const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) + + expect(prefix).toEqual([]) + expect(fs.readTargets).toEqual([instructionPath]) + expect(fs.readTextTargets).toEqual([]) + } finally { + await ctx.fiber.dispose() + await rm(dirname(root), { recursive: true, force: true }) + await rm(dirname(home), { recursive: true, force: true }) + } + }) + + it('aborts an in-flight baseline stream with the session-prefix signal', async () => { + const root = join(await tempRepo(), 'virtual-repo') + const home = join(await tempRepo(), 'virtual-home') + const ctx = new Context() + try { + await ctx.plugin(BlockingReadFileSystem) + const fs = ctx.fs as BlockingReadFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'blocked' }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const controller = new AbortController() + const reason = new Error('cancel prefix') + const empty: Message[] = [] + const pending = ctx.waterfall( + 'agent/session-prefix', stubAgent(root), empty, controller.signal, + () => Promise.resolve(empty), + ) + + await fs.started.promise + controller.abort(reason) + + await expect(pending).rejects.toBe(reason) + expect(fs.signals).toContain(controller.signal) + } finally { + await ctx.fiber.dispose() + await rm(dirname(root), { recursive: true, force: true }) + await rm(dirname(home), { recursive: true, force: true }) + } + }) + it('loads user-global and CLAUDE fallback content through ctx.fs', async () => { const root = await tempRepo() const home = await tempRepo() @@ -1340,11 +1475,9 @@ describe('workspace context request injection', () => { } }) const isolated = await import('@deepseek-ai/dsh-workspace-context') - const cache: InstructionContentCache = new Map() - - await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536, cache }) + await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 }) observedStats.clear() - await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536, cache }) + await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 }) expect(observedStats.get(join(root, 'AGENTS.md'))).toBe(1) } finally { @@ -1357,6 +1490,42 @@ describe('workspace context request injection', () => { }) describe('dynamic nested workspace context injection', () => { + it('propagates the tool execution signal into dynamic filesystem reconciliation', async () => { + const root = join(await tempRepo(), 'virtual-repo') + const home = join(await tempRepo(), 'virtual-home') + const ctx = new Context() + try { + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested' }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const controller = new AbortController() + const reason = new Error('cancel dynamic reconciliation') + controller.abort(reason) + const exec = stubToolExecution({ + callId: CallId('cancelled-dynamic-read'), + name: 'read', + arguments: { file_path: 'pkg/file.txt' }, + agent: stubAgent(root), + signal: controller.signal, + }) + + const pending = ctx.waterfall('tools/post-execute', exec, { + callId: exec.callId, + content: [{ type: 'text', text: 'ok' }], + isError: false, + }, () => Promise.resolve({ kind: 'accept' as const })) + + await expect(pending).rejects.toBe(reason) + expect(fs.signals).toContain(controller.signal) + } finally { + await ctx.fiber.dispose() + await rm(dirname(root), { recursive: true, force: true }) + await rm(dirname(home), { recursive: true, force: true }) + } + }) + it('attaches newly discovered nested instructions after a successful file read touches a descendant path', async () => { const root = await tempRepo() const home = await tempRepo() @@ -2086,6 +2255,142 @@ describe('dynamic nested workspace context injection', () => { } }) + it('does not commit pending state when an outer post-execute listener blocks the final result', async () => { + const root = await tempRepo() + const home = await tempRepo() + const ctx = new Context() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') + await write(join(root, 'pkg/deep/file.txt'), 'hello') + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalFileSystem, { cwd: '/' }) + await ctx.plugin(ToolFs) + let shouldBlock = true + ctx.on('tools/post-execute', async (_exec, _result, next) => { + const downstream = await next() + return shouldBlock + ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer policy block' }] } + : downstream + }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const blocked = await ctx.tools.execute({ + callId: CallId('outer-block-first'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent, + }) + shouldBlock = false + const accepted = await ctx.tools.execute({ + callId: CallId('outer-block-retry'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent, + }) + + expect(blocked.isError).toBe(true) + expect(blocked.additionalContexts).toBeUndefined() + expect(accepted.isError).toBe(false) + expect(blocksText(workspaceContextOf(accepted)?.content)).toContain('nested package rule') + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('rolls back parent-token pending state when a composite result is blocked', async () => { + const root = await tempRepo() + const home = await tempRepo() + const ctx = new Context() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') + await write(join(root, 'pkg/deep/file.txt'), 'hello') + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalFileSystem, { cwd: '/' }) + await ctx.plugin(ToolFs) + ctx.tools.register(defineTool({ + name: 'composite-read', + description: 'read through a nested dispatch', + parameters: {}, + async execute(_args, exec) { + const nested = await ctx.tools.execute({ + callId: CallId(`${exec.callId}:nested`), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + ...exec.agent === undefined ? {} : { agent: exec.agent }, + parent: exec.token, + ...exec.signal === undefined ? {} : { signal: exec.signal }, + }) + for (const context of nested.additionalContexts ?? []) exec.deferContext(context) + return nested.content + }, + })) + let shouldBlock = true + ctx.on('tools/post-execute', async (exec, _result, next) => { + const downstream = await next() + return exec.name === 'composite-read' && shouldBlock + ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer composite block' }] } + : downstream + }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const blocked = await ctx.tools.execute({ + callId: CallId('composite-first'), name: 'composite-read', arguments: {}, agent, + }) + shouldBlock = false + const accepted = await ctx.tools.execute({ + callId: CallId('composite-retry'), name: 'composite-read', arguments: {}, agent, + }) + + expect(blocked.isError).toBe(true) + expect(blocked.additionalContexts).toBeUndefined() + expect(accepted.isError).toBe(false) + expect(blocksText(workspaceContextOf(accepted)?.content)).toContain('nested package rule') + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('handles defensive tools/result observer branches without retaining staged state', async () => { + const ctx = new Context() + try { + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + const agent = stubAgent('/') + const parent = Symbol('parent') as ToolExecutionToken + const plainResult = { callId: CallId('plain'), content: [], isError: false } + + ctx.emit('tools/result', stubToolExecution({ + callId: CallId('agentless-child'), name: 'read', arguments: {}, parent, + }), plainResult) + ctx.emit('tools/result', stubToolExecution({ + callId: CallId('contextless-child'), name: 'read', arguments: {}, agent, parent, + }), { ...plainResult, additionalContexts: [{ content: [], source: { kind: 'plugin', plugin: 'workspace-context' } }] }) + ctx.emit('tools/result', stubToolExecution({ + callId: CallId('first-child'), name: 'read', arguments: {}, agent, parent, + }), { ...plainResult, additionalContexts: [workspaceChangeContext('first', 'one')] }) + ctx.emit('tools/result', stubToolExecution({ + callId: CallId('second-child'), name: 'read', arguments: {}, agent, parent, + }), { ...plainResult, additionalContexts: [workspaceChangeContext('second', 'two')] }) + ctx.emit('tools/result', { + ...stubToolExecution({ callId: CallId('agentless-parent'), name: 'composite', arguments: {} }), + token: parent, + }, plainResult) + + expect(agent.session.deriveMessages()).toEqual([]) + } finally { + await ctx.fiber.dispose() + } + }) + it('ignores post-execute events that are not successful structured file touches', async () => { const root = await tempRepo() const home = await tempRepo() @@ -2201,6 +2506,39 @@ describe('dynamic nested workspace context injection', () => { }) }) +describe('workspace context pending state', () => { + it('rolls back only the exact current transition and releases empty session state', () => { + const agent = stubAgent('/') + const pending = new WeakMap>() + + rollbackPendingInstructionChanges(agent, [{ + action: 'set', scope: 'missing', path: 'missing/AGENTS.md', digest: 'none', + }], pending) + expect(commitPendingInstructionContexts(agent, [{ + content: [], source: { kind: 'plugin', plugin: 'workspace-context' }, + }], pending)).toEqual([]) + + const committed = commitPendingInstructionContexts(agent, [ + workspaceChangeContext('first', 'one'), + workspaceChangeContext('second', 'two'), + ], pending) + const [first, second] = committed + expect(first).toBeDefined() + expect(second).toBeDefined() + + const [newer] = commitPendingInstructionContexts(agent, [workspaceChangeContext('first', 'newer')], pending) + rollbackPendingInstructionChanges(agent, [first!], pending) + rollbackPendingInstructionChanges(agent, [{ + action: 'set', scope: 'unknown', path: 'unknown/AGENTS.md', digest: 'unknown', + }], pending) + rollbackPendingInstructionChanges(agent, [second!], pending) + expect(pending.get(agent.session)?.get('first')?.change).toEqual(newer) + + rollbackPendingInstructionChanges(agent, [newer!], pending) + expect(pending.has(agent.session)).toBe(false) + }) +}) + describe('workspace context plugin export shape', () => { it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => { expect('default' in workspaceContext).toBe(false)