diff --git a/AGENTS.md b/AGENTS.md index 23d7c0263b..3158528e0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,9 @@ DeepSeek Harness SDK is a plugin-based agent harness on vendored Cordis: **every ``` vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md -packages/ Harness packages at packages///, all named @deepseek-ai/dsh- +packages/ @deepseek-ai/dsh- workspaces at packages/// core/ product API spine: session, system-prompt, tools, agent, agent-loop + prompt/ workspace instructions llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools diff --git a/docs/architecture.md b/docs/architecture.md index b0449be8ce..063c805272 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,9 +43,9 @@ Events form the service extension API; see the exhaustive [events catalog](cordi ### Event Domains -- **Session events** are durable, replayable facts. Turn and step boundaries, user input, assistant output, tool calls, tool results, steering, compaction records, and tool-owned durable facts append to the session log and flow through `session/event`. -- **Agent events** carry the live `Agent` handle for status, diagnostics, prompt admission, call-config shaping, result validation, and continuation policy. -- **Capability events** belong to the seam that owns the action. `tools/*`, `llm/*`, `system-prompt/*`, `fs/*`, and `subagent/*` let policy and adapters attach without importing the loop. +- **Session events** are durable, replayable facts: boundaries, messages, tool activity, steering, compaction, and tool-owned records append to the log and flow through `session/event`. +- **Agent events** carry the live `Agent` handle for status, diagnostics, prompt admission, request shaping, result validation, and continuation policy. +- **Capability events** belong to their owning seam; `tools/*`, `llm/*`, `system-prompt/*`, `fs/*`, and `subagent/*` attach policy and adapters without importing the loop. ### Interception Semantics @@ -53,7 +53,7 @@ Waterfall events behave like around-middleware: a listener delegates by calling ## Default Loop Lifecycle -The shipped loop drains work, assembles requests, streams model answers, executes tools, applies continuation policy, and checkpoints state. Every pause is a service call or event available to plugins. +The shipped loop drains work from prompt through checkpoint. Every pause is a service call or event available to plugins. A **session** is one agent's append-only event log. An ordinary **turn** claims one queued message; an injection turn claims none. A turn ends when the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus its tool executions. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. @@ -95,23 +95,23 @@ forever: checkpoint persistence and notify idle/running status ``` -Each successful `send()` adds one FIFO item. Queued items run as consecutive ordinary turns under one running interval, each after the prior turn's durability checkpoint. Each step has one prompt assembly. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unresolved references fail the turn. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). +Each successful `send()` adds one FIFO item. Queued items run as consecutive ordinary turns under one running interval, each after the prior turn's durability checkpoint. Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. ### Failure Boundaries -The turn is the containment boundary. A throwing listener, adapter error finish, or failed step ends the current turn with an error reason and reports live diagnostics through `agent/error`; it does not kill the driver loop. `cancel()` clears queued and steering work, aborts the active model/tool boundary when possible, and records the appropriate turn end. Disposal stops the loop, awaits quiescence, unregisters the agent, and lets service disposers drain. +The turn is the containment boundary. A throwing listener, adapter error finish, or failed step ends it with an error reason and reports `agent/error` without killing the driver. `cancel()` clears queued and steering work, aborts the active model/tool boundary when possible, and records the turn end. Disposal stops the loop, awaits quiescence, unregisters the agent, and drains service disposers. -Every session event is turn-enclosed. Reloading a crashed session preserves the interrupted tail and closes it with a synthetic `interrupted` turn end. A failure after the durable turn has closed reports through `agent/error` only because no safe in-turn position remains. A turn ends with one `TurnEndReason` (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `rejected`, or `interrupted`); per-variant semantics are in [session.md § TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). +Every session event is turn-enclosed. Reloading preserves an interrupted tail and closes it with a synthetic `interrupted` turn end. Failures after durable turn close report only through `agent/error` because no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. ### Agent Handles -`ctx.agents` owns live agents and returns `AgentHandle { agent, dispose() }`. Plugins drive `Agent` through `send()`, `steer()`, `inject()`, `cancel()`, and `whenIdle()`. The caller fiber and factory provider structurally co-own programmatic lifecycles; the consumer handle is the only other teardown capability, and all owners await one disposer. +`ctx.agents` owns live agents and returns `AgentHandle { agent, dispose() }`. Plugins drive `Agent` through `send()`, `steer()`, `inject()`, `cancel()`, and `whenIdle()`. The caller fiber and factory provider structurally co-own programmatic lifecycles; the consumer handle is the only other teardown capability. All owners await one disposer. ### Agent Scope -Every live agent owns a scoped `agent.ctx`. Its registrations shadow same-named globals, receive only that agent's dispatches, and unwind with the agent; async effects such as background-task cleanup are awaited. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. The [semantic-gates RFC](rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md) defines typed resolvers that derive carrier checks from merged `Events` signatures and `scopeTarget`, eliminating the handwritten event table. See the [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md); subagent composition controls are documented [separately](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). +Every live agent owns a scoped `agent.ctx`. Its registrations shadow globals, receive only that agent's dispatches, and unwind with it; async effects such as background-task cleanup are awaited. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. Typed resolvers derive carrier checks from merged `Events` signatures and `scopeTarget` ([semantic-gates RFC](rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). See the [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition controls](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). ## State @@ -125,7 +125,7 @@ Durability is a plugin concern. Persistence backends buffer synchronous `session ### Model Content -Messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`). The union derives from the merge-extensible `ContentBlockMap`; the same pattern types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New block types are coordinated across adapters, UI bridges, compaction pricing, and persistence, so block types remain a repo-wide contract. +Messages contain typed blocks (`text`, `reasoning`, `tool-call`, `tool-result`) derived from merge-extensible `ContentBlockMap`; the same pattern types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New block types coordinate adapters, UI bridges, compaction pricing, and persistence as one repo-wide contract. Streaming is a raw chunk protocol (`block-start` through `finish`) with `BlockAssembler` as the shared chunk-to-block assembler. The loop logs raw chunks while assembling them for dispatch. `LlmAdapter` is the provider seam: subclass, implement `stream()`, and register with `ctx.llm.registerAdapter(models, adapter)`. StreamChunk conventions live in [llm-streaming.md](core-data-structures/llm-streaming.md). @@ -135,11 +135,13 @@ Streaming is a raw chunk protocol (`block-start` through `finish`) with `BlockAs A swappable capability usually splits into **interface / implementation / consumer**: the interface owns its `ctx` key and events, an implementation registers a backend, and a consumer exposes model behavior through tools or prompts. Bash is the reference; the [capability graph](capability-seams.md) shows every family. -Some seams bend the template deliberately. LLM keeps interface and consumer vocabulary together because adapters are the implementations. Filesystem adds policy gates around provider primitives. Web is one service with search and fetch provider registries, so provider swaps do not rename model tools. Skills and subagents use named provider registries; local skills scan project/user roots, and other providers can add embedded or remote catalogs without registry/tool changes. Subagents spawn fresh, fork from the parent's completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). +Some seams bend the template deliberately: LLM combines interface and consumer because adapters implement it; filesystem wraps provider primitives with policy; web keeps search/fetch provider registries behind one service; skills and subagents use named providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). + +`dsh-workspace-context` composes baselines on `agent/session-prefix` and appends `ctx.fs`-discovered nested changes on `tools/post-execute`; its [RFC](rfc/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. ### Bundles And Apps -`dsh-agent-spine-demo` is the default composition bundle: one plugin loading the shared spine ([README](../packages/examples/agent-spine-demo/README.md)). App packages compose it with a front door and boot `bin`: `dsh-stdio-demo` for terminal REPL, and `dsh-acp-demo` for ACP over JSON-RPC stdio with no stdout logger ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` instead boots an external `cordis.yml`; the Python SDK injects the package default only when no explicit config channel is set and drives `dsh-jsonrpc` over line-delimited stdio JSON-RPC ([Python SDK](../python/README.md)). A deployment is a thin `cordis.yml` leaf: swappable backends, one app entry, and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). `dsh-stdio-demo` adds a terminal front door; `dsh-acp-demo` adds stdout-pure ACP over JSON-RPC ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies its default only without an explicit config channel and drives `dsh-jsonrpc` over line-delimited JSON-RPC ([Python SDK](../python/README.md)). Deployments remain thin leaves with swappable backends and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Where New Behavior Goes diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 91fe7c6497..c94b5d571e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -50,6 +50,8 @@ export interface Config { tools?: ToolsConfig /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ @@ -61,7 +63,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/acp-demo/src/index.ts:31`](../packages/examples/acp-demo/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:32`](../packages/examples/acp-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -95,14 +97,13 @@ Source: [`packages/core/agent-loop/src/index.ts:322`](../packages/core/agent-loo * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt * plugin (the deployment's persona section and the explicit model-facing tool * order), the `tools` object to the tool registry (its presentation `mode`), - * and `toolBash`/`toolTasks` to the two model-facing tool plugins this bundle - * owns. Producer opt-in stays producer-local: `toolBash` configures bash only; - * future background-capable tools remain independently composed plugins. - * Every field is optional INPUT here because each owner's schema - * supplies the default (`[]` / `''` / absent — lexicographic / `native`); the - * schema is the INTERSECTION of the owners' own schemas (the registry's - * nested under its `tools` key), so validation and defaulting can never - * drift from them. + * `skills` to the skill registry/local provider/tool consumer, + * `workspaceContext` to the workspace-context loader, and + * `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * Owner schemas supply defaults for optional input; workspace context instead + * requires an explicit byte budget or `false` because it changes model-visible + * input. Producer opt-in stays producer-local: `toolBash` configures bash only; + * independently composed producers keep their own config. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ @@ -113,6 +114,8 @@ export interface Config { toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ tools?: ToolsConfig + /** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */ + workspaceContext: workspaceContext.Config | false /** Skill registry, local provider, and model-facing consumer config. */ skills?: SkillConfig /** Model-facing bash tool config, including this producer's background opt-in. */ @@ -132,7 +135,7 @@ export interface SkillConfig { } ``` -Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) +Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) Source: [`packages/examples/agent-spine-demo/src/index.ts:55`](../packages/examples/agent-spine-demo/src/index.ts) @@ -270,7 +273,7 @@ export interface Config { } ``` -Source: [`packages/fs/fs-local/src/index.ts:35`](../packages/fs/fs-local/src/index.ts) +Source: [`packages/fs/fs-local/src/index.ts:38`](../packages/fs/fs-local/src/index.ts) ## `@deepseek-ai/dsh-hooks-claude` @@ -727,12 +730,14 @@ export interface Config { * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). */ resumeSessionId?: string + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] } ``` Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/stdio-demo/src/index.ts:36`](../packages/examples/stdio-demo/src/index.ts) +Source: [`packages/examples/stdio-demo/src/index.ts:37`](../packages/examples/stdio-demo/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` @@ -1073,7 +1078,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:307`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:322`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -1245,6 +1250,26 @@ export interface Config { Source: [`packages/workflow/workflow-workerthread/src/index.ts:32`](../packages/workflow/workflow-workerthread/src/index.ts) +## `@deepseek-ai/dsh-workspace-context` + +```ts config-catalog +/** User-facing workspace instruction loader configuration. */ +export interface Config { + /** Harness home containing the fixed user-global `AGENTS.md`; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string + /** Directory entries that identify the project root while walking upward from the session cwd. */ + 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/context/workspace-context/src/config.ts:16`](../packages/context/workspace-context/src/config.ts) + ## Loadable plugins with no config These load from a `cordis.yml` entry with no `config:` block; they declare no config surface. @@ -1285,6 +1310,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts)) +- `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ede449cb1c..e59ddd6f0b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -23,7 +23,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:139`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:151`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:148`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:160`](../../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:283`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -59,7 +59,7 @@ Awaited serial checkpoint for session-surface mutation after prompt assembly and Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -71,7 +71,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca 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:212`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -83,7 +83,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already 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:167`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -95,7 +95,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:236`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -107,7 +107,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:251`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -119,7 +119,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -131,7 +131,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -143,7 +143,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:250`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:262`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -155,7 +155,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:272`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -167,7 +167,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:270`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:282`](../../packages/core/agent/src/types.ts) ## `approval/*` @@ -195,7 +195,7 @@ Single-slot decision for the next FileSystem.editText. Calling `next()` yields a Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:59`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:61`](../../packages/fs/fs/src/index.ts) ### `fs/observed` — emit @@ -207,7 +207,7 @@ Record a successful observation. Listeners must be synchronous recorders: throws Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:68`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:70`](../../packages/fs/fs/src/index.ts) ### `fs/write-intent` — waterfall @@ -219,7 +219,7 @@ Single-slot decision for the next FileSystem.writeText. Calling `next()` yields Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:51`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:53`](../../packages/fs/fs/src/index.ts) ## `llm/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 18e00b7904..294e77adf1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -103,8 +103,9 @@ Source: [`packages/compact/compact/src/index.ts:36`](../../packages/compact/comp Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract. ```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 abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> abstract listDir(target: FsTarget, signal?: AbortSignal): Promise @@ -114,7 +115,7 @@ abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: F Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:78`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:80`](../../packages/fs/fs/src/index.ts) ## `ctx.llm` — `LlmService` @@ -201,7 +202,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:564`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:580`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` @@ -276,7 +277,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:363`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:378`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 8a78dbc79c..072ad629a6 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -246,6 +246,15 @@ The fifteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) +`InjectOptions` extends ordinary message attribution with context-only framing and durable model-hidden JSON metadata: + +```ts type-equiv +interface InjectOptions extends SendOptions { + envelope?: ContextEnvelope + meta?: JsonValue +} +``` + ```ts type-equiv interface Agent { readonly id: AgentId @@ -283,8 +292,10 @@ interface Agent { /** * Inject in-session context (file-change notices, skill content, cron * notifications, …): appends a `context/message` session event the next model - * request sees at its chronological position, rendered as tagged synthetic - * context rather than a user prompt. Does not run the model. + * request sees at its chronological position, rendered as synthetic context + * rather than a user prompt. The default uses the canonical context tag; + * `options.envelope: 'raw'` preserves caller-owned framing. Does not run the + * model. * * Turn-enclosure (the turn-enclosure RFC): an inject while a turn is open joins that turn; * an inject while idle wraps its `context/message` in a one-shot `injection` @@ -294,11 +305,11 @@ interface Agent { * (inject is synchronous): a failing flush is reported via `agent/error` * (step `0`) and the logger, never thrown into the caller. * - * Live-adapter review has validated the tagged-envelope rendering against - * current DeepSeek behavior; provider-specific mismatches belong in that - * adapter, not in the canonical session vocabulary. + * Live-adapter review has validated the canonical tagged-envelope rendering + * against current DeepSeek behavior; provider-specific mismatches belong in + * that adapter, not in the canonical session vocabulary. */ - inject(content: ContentBlock[], options?: SendOptions): void + inject(content: ContentBlock[], options?: InjectOptions): void /** * Cancel ALL pending work for the agent. `cancel()`: @@ -354,7 +365,7 @@ The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, che ## 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 envelope for model-facing context, `HookContext`, which is `inject()`ed as a `context/message` and so carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). +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) @@ -362,23 +373,25 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types interface HookContext { content: ContentBlock[] source: MessageSource + envelope?: ContextEnvelope + meta?: JsonValue } ``` -`agent/prompt-submit` returns a `PromptDecision` (allow the turn's claimed queued message — optionally rewriting its `content` or attaching `additionalContext` — or record `prompt/blocked` and end that zero-step turn as `rejected`): +`agent/prompt-submit` returns a `PromptDecision` (allow the turn's claimed queued message — optionally rewriting its `content` or attaching `additionalContexts` — or record `prompt/blocked` and end that zero-step turn as `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. diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 6c6dd3a130..6438b1ba03 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -37,6 +37,16 @@ interface FsInfo { } ``` +`lstat` is the path-level no-follow metadata primitive. It takes a path instead of an `FsTarget` because `resolve` intentionally follows symlinks to produce stable identity; consumers that need trust-boundary checks can call `lstat` first and reject `symlink` before resolving. + +```ts type-equiv +interface FsPathInfo { + version: FsVersion + type: 'file' | 'directory' | 'symlink' | 'other' + size?: number +} +``` + `listDir` returns direct child entries in stable name order. Each entry carries the child basename, type, resolved target, and cheap metadata when the backend can report it. It must not read file contents, so `size` is only for regular files and `version` is metadata-derived. Broken or disappeared children may be returned as `other` without metadata; permission or backend I/O failures while listing or resolving child metadata fail the whole listing with `FS_PERMISSION_DENIED` or `FS_IO_ERROR`. ```ts type-equiv @@ -144,4 +154,4 @@ type FsErrorCode = ## The service and the plugin -`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated wiring catalog shows the exact `ctx.fs` signatures on [services.md](../cordis-catalog/services.md#ctxfs--filesystem-abstract-seam). +`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `lstat`, `readText`, `streamText`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated wiring catalog shows the exact `ctx.fs` signatures on [services.md](../cordis-catalog/services.md#ctxfs--filesystem-abstract-seam). diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 18d511a0a0..2a73d8e5f9 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -4,6 +4,14 @@ The in-memory, event-sourced model of [dsh-session](../../packages/core/session) Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) +## Context framing + +`ContextEnvelope` selects the standard tagged projection or preserves a producer-owned complete frame. The latter changes framing only; the event remains a user-role `context/message` in chronological history. + +```ts type-equiv +type ContextEnvelope = 'context' | 'raw' +``` + ## `SessionEventMap` — the event vocabulary The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`). The generated [persistence log event catalog](../persistence-catalog.md) enumerates every member — core and merged — with its payload, surface badge, and declaration site. @@ -29,9 +37,16 @@ interface SessionEventMap { /** * In-session context injection (file-change notices, subdir AGENTS.md, * skill content, cron notifications, …). Rendered into the derived history - * as tagged synthetic context — NOT a user prompt. + * as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller + * supply its own complete framing; `meta` is persisted JSON hidden from the + * model. */ - 'context/message': { content: ContentBlock[]; source: MessageSource } + 'context/message': { + content: ContentBlock[] + source: MessageSource + envelope?: ContextEnvelope + meta?: JsonValue + } /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } /** @@ -222,7 +237,8 @@ export interface SurfaceFoldResult { - `user/message` → a user message. - `assistant/message` → an assistant message. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its `usage`, but a content-less assistant turn must not enter the provider transcript. - `tool/result` → a user message carrying a `tool-result` block. -- `context/message`, `steering/message` → user-role messages wrapped in a tagged envelope (``) at their chronological position — the "system-reminder" pattern; the model distinguishes them from real prompts by the envelope. +- `context/message` → a user-role message at its chronological position. The default `envelope` is `context`, which wraps content as ``; `envelope: 'raw'` uses caller-owned framing verbatim. Optional JSON `meta` remains in the event log and is never rendered. +- `steering/message` → a user-role message wrapped in `` at its chronological position. Everything else (`turn/*`, `step/*`) is structural and does not project into a message. Token usage is observed on `assistant/message.usage` (the step that produced it); an operational error's step number is on `turn/end.reason` for `kind: 'error'`. diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 46f2863f73..f6c634377c 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -10,7 +10,7 @@ A `ToolSchema` (the model-facing fields) plus the `execute` function and optiona ```ts type-equiv interface ToolDefinition extends ToolSchema { - execute(args: unknown, exec: ToolExecution): Promise + execute(args: unknown, exec: ToolRunContext): Promise /** * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. * Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it @@ -120,6 +120,19 @@ interface ToolExecutionInput { } ``` +A tool body receives the runtime extension. `deferContext()` is the composite-tool channel: it records nested-dispatch context without injecting inside the still-open outer call. + +```ts type-equiv +interface ToolRunContext extends ToolExecution { + /** + * Defer one nested-dispatch context until this tool's final result reaches + * the agent loop. Contexts retain their individual source, envelope, and + * metadata and are emitted in call order. + */ + deferContext(context: HookContext): void +} +``` + ```ts type-equiv interface ToolExecution extends ToolExecutionInput { /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ @@ -146,16 +159,14 @@ interface ToolExecutionResult { */ error?: ToolErrorInfo /** - * Extra model-facing context a `tools/post-execute` listener attached for the - * NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part - * of this call's `content` — `content`/`feedback` shape the tool RESULT, but - * `additionalContext` is a SEPARATE `context/message`. A step can carry - * multiple tool calls, so the loop BUFFERS every call's `additionalContext` - * and appends them only AFTER all `tool/result`s for the step, keeping - * tool-call/result adjacency intact. Carried on the result purely to ferry it - * from `execute()` up to the loop's per-step buffer. + * Extra model-facing contexts deferred by a composite tool or attached by + * `tools/post-execute` listeners for the NEXT request. They are NOT part of + * this call's `content`: the loop buffers every context and appends them only + * AFTER all `tool/result`s for the step, preserving tool-call/result + * adjacency. The array preserves each context's source, envelope, metadata, + * and production order instead of flattening mixed plugin provenance. */ - additionalContext?: HookContext + additionalContexts?: HookContext[] /** * The tool-private presentation payload from a successful `execute` (the object * return form). Threaded onto the `tool/result` session event and back into @@ -181,8 +192,8 @@ type PreToolDecision = ```ts type-equiv type PostToolDecision = - | { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext } - | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } + | { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } ``` Call `next()` for the default or return a decision to short-circuit. Pre-policy may deny or ask; only `allowed-once` proceeds, while a non-grant, missing approval channel or service, or agent-less request becomes a denial. Guards may still impose a final denial. Arguments cannot be rewritten because history, audit, UI, and execution must agree. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d8c924b24d..0e4f370267 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,27 +7,27 @@ 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:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../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:212`](../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:167`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../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) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../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`](../packages/ui/stdio) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../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:270`](../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:151`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:160`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:236`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:251`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:192`](../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) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:169`](../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`](../packages/ui/stdio) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:272`](../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:282`](../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:31`](../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:59`](../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:68`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:51`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../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:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:108`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -37,9 +37,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:98`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:98`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`workspace-context`](../packages/context/workspace-context) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:80`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 82763bdc55..10a02651da 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -9,6 +9,7 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri flowchart TD subgraph group_util["packages/util"] pkg_brand["brand"] + pkg_paths["paths"] pkg_timeout["timeout"] end subgraph group_llm["packages/llm"] @@ -107,6 +108,7 @@ flowchart TD end subgraph group_context["packages/context"] pkg_time_context["time-context"] + pkg_workspace_context["workspace-context"] end subgraph group_examples["packages/examples"] pkg_acp_demo["acp-demo"] @@ -287,6 +289,12 @@ flowchart TD pkg_tool_ask_user --> pkg_agent pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction + pkg_workspace_context --> pkg_agent + pkg_workspace_context --> pkg_fs + pkg_workspace_context --> pkg_llm + pkg_workspace_context --> pkg_paths + pkg_workspace_context --> pkg_session + pkg_workspace_context --> pkg_tools pkg_repeat_tool_guard --> pkg_agent pkg_repeat_tool_guard --> pkg_tools pkg_mcp_client --> pkg_llm @@ -342,6 +350,7 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tool_tasks pkg_agent_spine_demo --> pkg_tools + pkg_agent_spine_demo --> pkg_workspace_context pkg_workflow_workerthread --> pkg_agent pkg_workflow_workerthread --> pkg_brand pkg_workflow_workerthread --> pkg_llm @@ -361,6 +370,7 @@ flowchart TD pkg_acp_demo --> pkg_session_persistence_jsonl pkg_acp_demo --> pkg_tools pkg_acp_demo --> pkg_user_interaction + pkg_acp_demo --> pkg_workspace_context pkg_stdio_demo --> pkg_agent pkg_stdio_demo --> pkg_agent_spine_demo pkg_stdio_demo --> pkg_app_boot @@ -371,11 +381,13 @@ flowchart TD pkg_stdio_demo --> pkg_tool_ask_user pkg_stdio_demo --> pkg_tools pkg_stdio_demo --> pkg_user_interaction + pkg_stdio_demo --> pkg_workspace_context ``` | Package | Group | Depends on | | --- | --- | --- | | [`brand`](../packages/util/brand) | `util` | — | +| [`paths`](../packages/util/paths) | `util` | — | | [`timeout`](../packages/util/timeout) | `util` | — | | [`scope`](../packages/core/scope) | `core` | — | | [`skill`](../packages/skill/skill) | `skill` | — | @@ -437,6 +449,7 @@ flowchart TD | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | @@ -447,9 +460,9 @@ flowchart TD | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 64a0625f36..6c48285d99 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -57,7 +57,7 @@ Raw stream chunk — token-level replay fidelity. Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -69,7 +69,7 @@ Assembled assistant message for one step (derived history uses this). Carries th Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:260`](../packages/core/session/src/types.ts) ### `bash/*` @@ -121,15 +121,15 @@ Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact #### `context/message` — surface -In-session context injection (file-change notices, subdir AGENTS.md, skill content, cron notifications, …). Rendered into the derived history as tagged synthetic context — NOT a user prompt. +In-session context injection (file-change notices, subdir AGENTS.md, skill content, cron notifications, …). Rendered into the derived history as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller own the complete model-facing frame; `meta` is durable JSON state omitted from the model projection. ```ts persistence-catalog -'context/message': { content: ContentBlock[]; source: MessageSource } +'context/message': { content: ContentBlock[]; source: MessageSource; envelope?: ContextEnvelope; meta?: JsonValue } ``` Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) ### `hook/*` @@ -177,7 +177,7 @@ Durable record of a prompt veto and its reason. It is log-only: the blocked prom Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) ### `request/*` @@ -189,7 +189,7 @@ Full EpochHeader for the next request, appended inside its step before dispatch. 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) #### `request/header-delta` — log-only @@ -199,7 +199,7 @@ Log-only amendment to the folded EpochHeader. System and tools use their delta c 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } ``` -Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) ### `steering/*` @@ -213,7 +213,7 @@ Steering content injected between steps of a running turn. Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:267`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) ### `step/*` @@ -225,7 +225,7 @@ Closes step `step` of turn `turn`. 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:231`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -235,7 +235,7 @@ Opens step `step` of turn `turn` — one model call plus the tool executions it 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:225`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:229`](../packages/core/session/src/types.ts) ### `todo/*` @@ -249,7 +249,7 @@ Whole-list snapshot; the latest write wins on replay. It is log-only UI state an Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:272`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) ### `tool/*` @@ -263,11 +263,11 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only -One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the deterministic sub-call id (`:code:`), the tool `name` with its JSON-normalized `arguments` — the exact value dispatched, normalized before dispatch, so this append can never fail on payload shape — whether the sub-call errored, and a bounded `resultSummary` of its model-facing text. +One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the deterministic sub-call id (`:code:`), the tool `name` with its JSON-normalized `arguments` — the exact value dispatched, normalized BEFORE dispatch, so this append can never fail on payload shape — whether the sub-call errored, and a bounded `resultSummary` of its model-facing text. Before bounding, occurrences of a non-root session workspace path are normalized to `.` so host-specific absolute path lengths cannot change the summary. Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter model context; persistence and UIs get every call. Appended inside the parent `run_code`'s execution (the bridge drains its queue before returning), so the turn-enclosure invariant holds by construction. ```ts persistence-catalog 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string } @@ -275,7 +275,7 @@ One bridged sub-dispatch from a `run_code` program: the parent `run_code` call i Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/tools/src/code-mode.ts:25`](../packages/core/tools/src/code-mode.ts) +Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/code-mode.ts) #### `tool/result` — surface @@ -287,7 +287,7 @@ A completed tool call's model-facing result, plus an optional tool-private `meta Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:265`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) ### `turn/*` @@ -301,7 +301,7 @@ Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awai Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -313,7 +313,7 @@ Opens turn `turn`. `trigger` records what started it — one claimed queued mess Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:217`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:221`](../packages/core/session/src/types.ts) ### `user/*` @@ -327,4 +327,4 @@ A user-visible prompt (queued message drained at turn start). Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:229`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index b382cbc945..af1721775d 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -61,6 +61,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 | | [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 | | [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 | +| [Workspace context instruction files](implemented/feature/2026-06-24-workspace-context.md) | 2026-06-24 | | [Ask-user question capability](implemented/feature/2026-06-25-ask-user-question.md) | 2026-06-25 | | [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 | | [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 | diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md index 71efeff826..0121a11fa0 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -81,7 +81,7 @@ Resolved targets must expose at least three concepts: - An opaque `targetKey`, used for stale guards and file-state lookup. The local backend might use a realpath-like key; a remote backend might use a workspace URI or file id. Consumers must not parse or assume this is a local absolute path. - A `displayPath`, used for model/UI-facing output. It may be a local absolute path, workspace-relative path, or remote URI depending on the backend. -Read and mutation results must include an opaque file `version`. A local backend can use mtime/size or a hash-like token; a remote backend can use a revision id. The `dsh-fs-policy` plugin records versions for stale checks; consumers may display related metadata but must not interpret the version token. +Read and mutation results must include an opaque file `version`. The local backend derives its token from bigint stat metadata (`dev`, `ino`, `size`, `mtimeNs`, and `ctimeNs`) so same-size rewrites and inode replacement invalidate consumers reliably; a remote backend can use a revision id or hash-like token. The `dsh-fs-policy` plugin records versions for stale checks; consumers may display related metadata but must not interpret the version token. The provider hands back decoded text: `readText` returns a whole regular text file, `streamText` streams the same text semantics for large files. Both own regular-file checks, bounded line/output handling is NOT theirs — line windowing, numbered-line rendering, and total-line accounting live in the executor (`dsh-tool-fs`), which reads through `ctx.fs` and renders the model-facing window. The provider owns UTF-8 decoding and binary/NUL rejection; it does not know about line windows or views. 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 fad851265d..f60607df4d 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 @@ Filesystem resolution used one plugin-load cwd while bash used the session proje 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 d95a709ada..86b352c5b6 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -44,7 +44,7 @@ Like MiniCode, the conversation advances append-only and resets only when model- ## 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()`, a `tools/post-execute` decision's `additionalContext`, 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-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md index 950c2b4bd7..3bc07b839f 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -38,11 +38,11 @@ Three decisions, each elaborated in its own section below: Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentation transport with one required parameter, `{ code: string }`. It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove Code Mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`: -1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding JSON-normalizes its arguments—rejecting lossy values before dispatch—waits on the serialization queue, executes with a deterministic call id and the outer token as `parent`, and logs `tool/code-dispatch`. Successful text becomes a string and non-text blocks become placeholders; tool errors reject the binding promise. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline. +1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding JSON-normalizes its arguments—rejecting lossy values before dispatch—waits on the serialization queue, executes with a deterministic call id and the outer token as `parent`, defers returned contexts through the outer execution, and logs `tool/code-dispatch`. Successful text becomes a string and non-text blocks become placeholders; tool errors reject the binding promise. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline. 2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`. The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime. 3. **Settle after quiescence.** When the runtime settles, the bridge aborts outstanding work and drains the dispatch queue before returning. Success returns captured output and presentation metadata. A runtime failure becomes `CodeRunFailedError`; backend rejection uses the registry's normal error boundary. Both produce structured error results, and no sub-call can append after `run_code` settles. -**Sub-call `additionalContext` is omitted.** Injecting it during `run_code` would break parent call/result adjacency, while one program can produce many contexts. Supporting it requires a plural channel or loop-level sub-dispatch buffer. +**Sub-call contexts are deferred through the parent.** Injecting inside `run_code` would break parent call/result adjacency, so `ToolRunContext.deferContext()` collects every sub-result `additionalContexts` entry in dispatch order. The registry carries that array even when the program later throws, and the loop appends each entry only after the outer result and every sibling result in the step. An outer post-execute block discards tool-deferred entries and exposes only contexts explicitly attached by the blocking decision. **Concurrency is serialized.** Each run owns a dispatch queue, so even `Promise.all` executes tool calls in submission order. Settlement abandons queued calls that have not started. Parallelism requires per-tool concurrency-safety metadata. @@ -86,14 +86,14 @@ The SDK instructs the model to write an async erasable-TypeScript body, call too ## Consequences -Deployments switching to `'code'` must update any native-only `toolOrder`. Assembly listeners own the integrity of any rewritten protocol surface. Sub-dispatch remains serialized, and the bridge does not propagate per-call `additionalContext` until those contracts are designed for Code Mode. +Deployments switching to `'code'` must update any native-only `toolOrder`. Assembly listeners own the integrity of any rewritten protocol surface. Sub-dispatch remains serialized, while per-call contexts retain their source, envelope, and metadata through the outer result. ## Testing - **Worker runtime:** Real-worker tests cover output and value capture, failure kinds, compute and wall budgets, hostile binding traffic, empty environment, structured-clone fallback, output caps, and disposal to quiescence. A built-package test runs the worker entry under plain Node. -- **Registry integration:** Tests cover code generation, all presentation modes, reserved-name and restriction rules, scoped visibility, authoritative assembly rewrites, `toolOrder`, runtime compatibility failures, full-pipeline sub-dispatch, parent-token correlation, serialization, cancellation and queue drain, JSON normalization, error propagation, log events, omitted `additionalContext`, and HMR cleanup. -- **With-key e2e:** A real model composes two bash calls in one program; the test verifies the collapsed request header, correlated dispatch events, resulting file, and curated answer. -- **Snapshot:** The `code-mode-turn` and `both-mode-turn` fixtures pin the SDK section, header tool list, dispatch events, and result card. +- **Registry integration:** Tests cover code generation, all presentation modes, reserved-name and restriction rules, scoped visibility, authoritative assembly rewrites, `toolOrder`, runtime compatibility failures, full-pipeline sub-dispatch, parent-token correlation, serialization, cancellation and queue drain, JSON normalization, error propagation, log events, ordered context deferral across successful and failed programs, outer-block suppression, and HMR cleanup. +- **With-key e2e:** A real model composes two bash calls in one program; another discovers nested workspace instructions through a Code Mode fs dispatch. The tests verify collapsed request headers, correlated dispatch events, resulting files, deferred context, and model behavior. +- **Snapshot:** The `code-mode-turn`, `both-mode-turn`, and `code-mode-workspace-context` fixtures pin SDK text, header tool lists, dispatch events, deferred context, and result cards. ## Alternatives considered diff --git a/docs/rfc/implemented/feature/2026-06-24-workspace-context.md b/docs/rfc/implemented/feature/2026-06-24-workspace-context.md new file mode 100644 index 0000000000..cc11fd19ff --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-24-workspace-context.md @@ -0,0 +1,87 @@ +# RFC: Workspace context instruction files + +Status: implemented + +## Problem + +Repository guidance such as `AGENTS.md` belongs in a coding session's effective context so project conventions, build commands, and review rules arrive without repeated user pasting. The stdio and ACP products need the same behavior, isolated by session cwd: a global system-prompt section leaks one workspace's files into another live ACP session. + +Neighboring products establish useful conventions but differ in details. Codex treats `AGENTS.md` as native, Claude Code uses `CLAUDE.md` and familiar system-reminder-style user context, and opencode supports both names with one winner per directory plus lazy nested discovery. The harness needs cross-tool compatibility without loading duplicate or contradictory files from the same scope. + +The lifecycle has two distinct classes of content. The initial applicable chain is stable enough to live in the request prefix and benefit from provider prefix caching. Nested files, edits, candidate switches, and removals happen after the session starts and belong in durable append-only history rather than the frozen prefix. + +## Decision + +The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-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. 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. Once `lstat` identifies a regular-file winner, a provider exception or disagreement during resolve/stat is classified as unavailable: it is neither interpreted as a deletion nor allowed to fall through to a lower-priority candidate. + +### File Names And Precedence + +The default per-directory candidate list is `['AGENTS.md', 'CLAUDE.md']`. The list is configurable as `instructionFileCandidates`, and `AGENTS.md` is an ordinary first candidate rather than a hidden priority. In one directory, only the first existing regular-file candidate loads. With defaults, `AGENTS.md` is native and `CLAUDE.md` is a compatibility fallback. + +Candidate entries are same-directory file names. Empty entries, `.`/`..`, and entries containing `/` or `\` are ignored. Lowercase names, local variants, and other same-directory names can be opted into explicitly; rule directories and import semantics are outside this contract. + +The user-global file is fixed at `$DSH_HOME/AGENTS.md` and is not affected by `instructionFileCandidates`. `$DSH_HOME` defaults to `~/.dsh`, matching the harness-level home role of `~/.codex` or `~/.claude` rather than introducing a plugin-specific home. Tilde expansion and the default live in `dsh-paths` so future harness features share the same convention. + +### Baseline Prefix + +On the first request of an agent-loop instance, the plugin contributes one user-role message through `agent/session-prefix`. It loads the user-global file first, then finds the project root by walking upward from `agent.session.header.cwd` to a configured root marker (default `.git`), then loads one candidate from each directory from the root to the cwd. A `.git` file and a `.git` directory are both valid markers, covering linked worktrees and submodules. Without a marker, the cwd itself is the root. + +The plugin prepends its contribution before `await next()` returns, so session-prefix contributions appear in plugin registration order. In the product spine workspace instructions are registered before a skills catalog and therefore appear first. The loop deep-freezes the composed prefix, logs it in `EpochHeader.messagePrefix`, and reuses it verbatim for that instance. It is request state, not `Session.deriveMessages()` history. + +A resumed agent creates a new loop instance and recomposes the baseline from current files, with the new prefix anchored by the resume request header. This permits current baseline content on resume without mutating a prefix already used by an earlier instance. + +The baseline is a user-role `` with `Instructions from: ` sections and explicit authority and precedence language. This familiar model-facing frame avoids a harness-specific XML vocabulary. Project paths are root-relative and the user-global path is `~/.dsh/AGENTS.md` for the default home or `$DSH_HOME/AGENTS.md` for a configured home. A literal `` inside file content is escaped. The package README owns the exact current [prompt shape](../../../../packages/context/workspace-context/README.md#prompt-shape). + +### Dynamic Discovery And Refresh + +After a successful first-party `read`, `write`, or `edit` call, the `tools/post-execute` listener reconciles the touched descendant chain and every scope already known to the session. A newly reached scope is returned through `additionalContexts` for the next request using an `Additional instructions from: ` system-reminder. Under Code Mode, `run_code` defers sub-dispatch contexts onto its outer result, so the same update is appended only after the parent result rather than being injected mid-call. + +A content edit appends `Updated instructions from: `, states that the new content replaces the previous content, and includes the complete current file. If precedence changes from one candidate to another, the message also names the previous path and says it no longer applies. If no candidate remains, the plugin appends `Instructions removed: ` and states that the previously loaded instructions no longer apply. + +Dynamic messages use a raw `context/message` envelope because the plugin owns the complete system-reminder framing. Core context injection therefore supports `envelope: 'raw'`; callers that omit it retain the canonical `` wrapper. `context/message.meta` carries opaque JSON state that is persisted but never rendered to the model. + +Shell commands are not discovery triggers. Local bash calls start fresh shells, and inferring reached paths from arbitrary command strings would require shell semantics the prompt plugin does not own. + +### Duplicate Suppression And Change Detection + +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 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. Each entry records the open `{ turn, step }`: an equal durable `context/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. 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. + +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. + +The frozen baseline keeps an in-memory path/digest map for comparison. A later successful filesystem touch appends baseline edits or removals as dynamic messages; it never rewrites the prefix. During resumed prefix composition the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the first resumed request. + +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 Bounded Reads + +`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. + +`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 cache and never retains instruction prose. It keeps only `{ path, version, digest }` per effective scope in a `WeakMap>`: a matching provider `FsVersion` plus matching effective prompt state skips the read, while a changed version triggers a bounded read and SHA-1 confirmation. SHA-1 remains the cross-provider content identity persisted in visible structured metadata; provider versions are only an in-memory invalidation fast path. Cache transitions for model-visible changes commit only when the corresponding context survives the complete tool-result policy chain, and are invalidated if that accepted context is later dropped with its aborted step before reaching the log. + +## Alternatives considered + +**Use a global `ctx.systemPrompt.section()`.** Rejected because one Cordis context can host sessions with different cwd values, while repository-owned text is lower-authority context rather than top-authority provider system content. + +**Inject the baseline on every `agent/pre-step`.** Rejected because repeated history injection wastes tokens, complicates duplicate state, and prevents a structurally stable provider prefix. Prefix composition gives a frozen, logged, per-instance baseline while dynamic append-only messages handle changes. + +**Load both `AGENTS.md` and `CLAUDE.md` in one directory.** Rejected because repositories in transition commonly duplicate guidance across both files. Ordered candidates make precedence explicit and configurable. + +**Parse rendered headings or hidden comments to recover loaded state.** Rejected because instruction prose can contain the same text, causing silent false positives. Persisted JSON metadata provides an unambiguous state channel that is invisible to the model. + +**Summarize files with a model.** Rejected because instruction files are already curated summaries; another model call is nondeterministic and can erase edge-case requirements. Deterministic full text with byte budgeting is simpler. + +## 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 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. + +The system is event-driven rather than watch-driven. Edits are not visible at the exact filesystem mutation instant unless that mutation goes through a structured tool; externally changed files are noticed on the next successful structured touch or resume. This keeps the design deterministic and provider-neutral. + +## Deferred + +Bash-derived path reporting, recursive startup scans, file watchers, lowercase defaults, `.claude/CLAUDE.md`, `.claude/rules/*.md`, import directives, ACP `additionalDirectories`, trust acknowledgements, and model-generated summaries are deferred. Same-directory private variants can be configured today; directory rule systems and imports need their own precedence and trust designs. 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 2d285fb152..e5df9c9548 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' }`, so every bridge `inject()` and `HookContext` passes `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`. Unit coverage pins the resulting `context/message.source` as the plugin rather than the user. -### Adding context is not a veto — delegate, then fold +### Adding context is not a veto — delegate, then prepend -A context-only hook must call `next()` and then fold its `additionalContext` into the downstream decision; returning allow or accept directly would bypass later policy listeners. Post-tool block and accept decisions both preserve added context. Prompt allow preserves it, while prompt block drops it because the prompt never reaches the model. Only an explicit hook denial or block short-circuits the waterfall. +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 669e577625..50ff4a3af6 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 for the turn's single claimed queued message before the `user/message` append. `allow` optionally rewrites the prompt `content` or attaches `additionalContext`; `block` appends a durable `prompt/blocked` and rejects that zero-step turn. +- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired for the turn's single claimed queued message before the `user/message` append. `allow` optionally rewrites the prompt `content` or attaches separately sourced `additionalContexts[]`; `block` appends a durable `prompt/blocked` and rejects that zero-step turn. -**`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 @@ -25,7 +25,7 @@ Every call follows `tools/pre-execute` → guards → `tools/execute` → dispat - **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks. Deny skips `tools/execute` and core dispatch. Ask resolves through the optional approval seam: only `allowed-once` continues through guards and dispatch; rejection, cancellation, an unavailable channel, a missing approval service, or an agent-less call becomes a normalized denial. Every outcome still reaches post-policy and final observers. - **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids. - **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may add, replace, or remove only `exec.signal` before doing so, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch. -- **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, optionally replaces content, or attaches `additionalContext`; in-place mutation of the result is not a transform channel, because the registry rebuilds the outcome from a protected snapshot plus the returned decision. +- **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, optionally replaces content, or attaches `additionalContexts`. The returned decision is the supported transform channel; after the waterfall, the registry materializes the complete outcome once before final observation. - **`tools/result`** is the synchronous contained notification after every transform, lossless-JSON materialization, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or reject `ToolRegistry.execute()`'s returned outcome. Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool, and a final observer sees exactly what the caller receives and the session log can persist. @@ -34,9 +34,9 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li ### Three load-bearing loop decisions -1. **Open the turn before prompt policy.** A blocked prompt becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. The veto records `prompt/blocked` with the original prompt and reason, while allowed `additionalContext` is injected into the open turn. Each ordinary send owns an independent turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md). +1. **Open the turn before prompt policy.** A blocked prompt becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. The veto records `prompt/blocked` with the original prompt and reason, while every allowed `additionalContexts` entry is injected into the open turn. Each ordinary send owns an independent turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md). -2. **Post-tool `additionalContext` is buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`, and the loop buffers every per-call context for the step and appends them as `context/message`(s) only after every `tool/result` is appended. +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. 3. **A forced `continue` `reason` is enqueued through the steering channel**, so the next step's top-of-loop drain records it as steering for the continued turn — next-*step* steering within the SAME turn, not a next-*turn* prompt (matching the existing `hasSteering` force-continue override). 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 f0d458368a..34cdb8789d 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/feature/2026-07-08-repeat-tool-guard.md b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md index 324aa37256..4996fc2935 100644 --- a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md @@ -14,7 +14,7 @@ The guard is a loop-hygiene plugin, not a model-facing tool. It counts consecuti The plugin is `@deepseek-ai/dsh-repeat-tool-guard` at `packages/guard/repeat-tool-guard/`, opening the `guard/` group for loop-hygiene plugins (single-package groups have precedent: [the todo-write RFC](2026-06-29-todo-write-tool.md) shipped `todo/tool-todo`). It registers three listeners and holds all state in plugin-local maps keyed by `AgentId` — the tool registry is a context-level singleton whose waterfalls interleave every agent's calls (subagents run on the same context), so per-agent keying is correctness, not polish. -- **`tools/post-execute` (waterfall)** — the one detection point. The listener receives `(exec, result)` together, so counting and reminder delivery need no cross-event pending map (the pi extension needs one only because its `tool_call`/`tool_result` hooks are separate events). It always delegates via `next()` and, when a threshold is hit, folds a reminder onto the downstream decision's `additionalContext` — the observe-and-enrich posture [the hooks bridges](2026-06-30-hook-bridges.md) already use, honoring the waterfall contract. Counting happens here rather than in `tools/pre-execute` because post-execute also runs for denied calls (`ToolRegistry.execute` routes a deny through the same pipeline), and a model hammering a denied call is exactly the loop worth breaking. +- **`tools/post-execute` (waterfall)** — the one detection point. The listener receives `(exec, result)` together, so counting and reminder delivery need no cross-event pending map (the pi extension needs one only because its `tool_call`/`tool_result` hooks are separate events). It always delegates via `next()` and, when a threshold is hit, prepends a reminder to the downstream decision's `additionalContexts` — the observe-and-enrich posture [the hooks bridges](2026-06-30-hook-bridges.md) already use, honoring the waterfall contract. Counting happens here rather than in `tools/pre-execute` because post-execute also runs for denied calls (`ToolRegistry.execute` routes a deny through the same pipeline), and a model hammering a denied call is exactly the loop worth breaking. - **`agent/prompt-submit` (waterfall)** — pure reset hook: delegate via `next()`, clear the submitting agent's chain. A user interjection changes the context; repetition across it is not a loop. - **`agent/status` (emit)** — on `disposed`, drop the agent's state, bounding the maps over harness lifetime. @@ -29,7 +29,7 @@ Two deliberate rules, both documented in [the package README](../../../../packag ### Reminder delivery -Reminders use `additionalContext` with the plugin source, preserving the original `tool/result`. The first threshold emits a short nudge; later thresholds include the tool, count, and a bounded argument preview while comparison still uses the full canonical string. Existing downstream context is concatenated under the guard's source because `HookContext` supports one source. +Reminders ride `additionalContexts` as their own entries (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}` — the label is load-bearing per `HookContext`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit, and the loop appends buffered contexts as `context/message`s after the step's results, which the session renders as tagged synthetic-user envelopes and derived history replays. Thresholds escalate: the first configured threshold gets a short "you are repeating yourself, analyze the previous result" nudge; each later threshold gets the detailed form naming the tool, the repeat count, and the canonical arguments (head-truncated at `argumentsPreviewChars`, default 500 — a looping `write`-sized payload must not ride into the next request unbounded; the chain key always compares the full canonical string), and stating that the calls made no progress. The pi original hardcodes the gentle text to the literal count 3; the guard keys it to `thresholds[0]`, fixing that bug in the port. A downstream hook bridge contribution remains a separate array entry, so both plugins retain their source, envelope, and metadata. ### Config @@ -53,7 +53,7 @@ Reminders use `additionalContext` with the plugin source, preserving the origina ## Alternatives considered -- **Append the reminder into the tool result** (`accept` with replaced `content` — the pi extension's mechanism, which patches result content because that is the only channel its API offers) — rejected: it makes the logged `tool/result` lie about what the tool returned, and `additionalContext` exists precisely as the separate sanctioned channel for post-execute commentary, with loop-level buffering that preserves call/result adjacency. +- **Append the reminder into the tool result** (`accept` with replaced `content` — the pi extension's mechanism, which patches result content because that is the only channel its API offers) — rejected: it makes the logged `tool/result` lie about what the tool returned, and `additionalContexts` is the separate sanctioned channel for post-execute commentary, with loop-level buffering that preserves call/result adjacency. - **Count in `tools/pre-execute` with a pending-reminder map** (the pi two-phase shape) — rejected: post-execute alone sees `(exec, result)` together and also fires for denied calls, so one listener with no cross-event state covers strictly more attempts with less machinery. - **Escalate to `block` at the highest threshold** — rejected for the initial scope: a blocked call punishes legitimate identical repeats (polling a long-running terminal, re-checking a file the agent expects to change), and an advisory reminder keeps the model in control. Revisit with evidence; the decision shape (`PostToolDecision`) already supports it. - **A per-deployment external hook via the CC/Codex bridges** (a `PostToolUse` script) — rejected as the answer: it works for one deployment, but a shipped, unit-tested, `cordis.yml`-configurable plugin is the harness-native form, without per-call subprocess cost. @@ -65,7 +65,8 @@ Reminders use `additionalContext` with the plugin source, preserving the origina - The reminder is advisory by design: idempotent polling patterns that repeat identical calls on purpose still receive nudges past the thresholds, and the pressure valves are config (`thresholds`, `exclude`) plus reminder text that explicitly allows finishing when enough evidence has been gathered. Each trigger costs reminder tokens on the next request; thresholds bound the frequency. - Chain state is in-memory only: a session resumed from persistence starts with a fresh chain, so a loop spanning a resume draws its reminders later than a live one — accepted, the guard is a heuristic nudge, not a logged invariant, and persisting counter state would buy little for real complexity. -- When multiple post-execute producers attach context on one call, the fold concatenates under the guard's `source`; ordering between plugins follows listener registration order. The seam cannot represent mixed provenance — a limit inherited from `HookContext`, not owned by this plugin. +- When multiple post-execute producers attach context on one call, each contribution stays a separate `HookContext`; ordering follows waterfall nesting and each entry retains its own provenance. +- Implementing the snapshot tier surfaced a hidden assumption in the suite kit: the fixture guard equated "authored model scenario" with "override-driven". The `Scenario` table now carries an explicit `overridden` flag, and the sidecar's presence is checked BOTH ways against it (an unregistered stray sidecar would silently replace the derived script) — the suite kit is stricter than it was before this plugin existed. ## Deferred 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/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index 51641d4655..8db1a68099 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -20,7 +20,7 @@ flowchart TD owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/code-dispatch"] post["tools/post-execute waterfall
accept, block, replace, add context"] final["tools/result synchronous notification
frozen authoritative outcome"] - context["Buffered additionalContext
context/message after all tool results"] + context["Buffered additionalContexts
context/message after all tool results"] toolResult["Session event: tool/result
single model-facing outcome"] allResults["All calls in the step settled
and tool/result events recorded"] presentResult["UI completed card
presentResult(args, result)"] @@ -48,6 +48,6 @@ flowchart TD allResults --> context ``` -Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContext` to preserve call/result adjacency. +Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency. Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs. diff --git a/examples/acp-agent/advanced.cordis.snapshot.yml b/examples/acp-agent/advanced.cordis.snapshot.yml index 3c8a57f32a..24d944ea47 100644 --- a/examples/acp-agent/advanced.cordis.snapshot.yml +++ b/examples/acp-agent/advanced.cordis.snapshot.yml @@ -12,6 +12,8 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 tools: mode: both persona: | diff --git a/examples/acp-agent/advanced.cordis.yml b/examples/acp-agent/advanced.cordis.yml index 630a9c10f7..16d913e4a0 100644 --- a/examples/acp-agent/advanced.cordis.yml +++ b/examples/acp-agent/advanced.cordis.yml @@ -10,6 +10,8 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 tools: mode: both persona: | diff --git a/examples/acp-agent/both-mode.cordis.snapshot.yml b/examples/acp-agent/both-mode.cordis.snapshot.yml index c82025252d..0da3c5a644 100644 --- a/examples/acp-agent/both-mode.cordis.snapshot.yml +++ b/examples/acp-agent/both-mode.cordis.snapshot.yml @@ -14,6 +14,8 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 tools: mode: both persona: | diff --git a/examples/acp-agent/both-mode.cordis.yml b/examples/acp-agent/both-mode.cordis.yml index 1624e43330..a7fc0b924c 100644 --- a/examples/acp-agent/both-mode.cordis.yml +++ b/examples/acp-agent/both-mode.cordis.yml @@ -12,6 +12,8 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 tools: mode: both persona: | diff --git a/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml b/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml new file mode 100644 index 0000000000..9d3197fc8a --- /dev/null +++ b/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml @@ -0,0 +1,36 @@ +# Keyless replay counterpart of code-mode-workspace-context.cordis.yml. It +# enables the filesystem entries needed by this scenario and swaps in replay. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 + tools: + mode: code + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + - id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/code-mode-workspace-context.cordis.yml b/examples/acp-agent/code-mode-workspace-context.cordis.yml new file mode 100644 index 0000000000..3dbb1d1f44 --- /dev/null +++ b/examples/acp-agent/code-mode-workspace-context.cordis.yml @@ -0,0 +1,31 @@ +# Code Mode workspace-context snapshot recording overlay. The scenario needs +# filesystem tools to trigger nested instruction discovery after a read. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 + tools: + mode: code + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + - id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/acp-agent/code-mode.cordis.snapshot.yml b/examples/acp-agent/code-mode.cordis.snapshot.yml index ea3de598d8..624255716e 100644 --- a/examples/acp-agent/code-mode.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode.cordis.snapshot.yml @@ -14,6 +14,8 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 tools: mode: code persona: | diff --git a/examples/acp-agent/code-mode.cordis.yml b/examples/acp-agent/code-mode.cordis.yml index 0170e808ce..16dbb56586 100644 --- a/examples/acp-agent/code-mode.cordis.yml +++ b/examples/acp-agent/code-mode.cordis.yml @@ -13,6 +13,8 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 tools: mode: code persona: | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 345fd49b45..8495730a6b 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -33,7 +33,7 @@ - id: permission name: '@deepseek-ai/dsh-permission' -# The ACP server app: the agent-core spine + JSONL persistence + the ACP bridge. +# The ACP server app: the agent-spine-demo spine + JSONL persistence + the ACP bridge. # Persistence root: $DSH_SNAPSHOT_SESSIONS_ROOT when the snapshot harness sets it # (so it can harvest / isolate the log), else ./.sessions for the demo. - id: acp-agent @@ -41,6 +41,8 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 # Keep the persona to identity and behavior; tool plugins own tool guidance. # The loop resolves {{model}} and each ACP session's client-supplied {{cwd}}. persona: | diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 3e605b3141..1844d32f52 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -25,7 +25,9 @@ const AGENT = { // The Code Mode overlay configs (include-patched variants of cordis.yml; the // replay swap resolves each one's sibling `*cordis.snapshot.yml`). const CODE_MODE_CONFIG = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) +const CODE_MODE_WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../code-mode-workspace-context.cordis.yml', import.meta.url)) const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import.meta.url)) +const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cordis.yml', import.meta.url)) const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url)) const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url)) @@ -68,6 +70,19 @@ const SCENARIOS: Scenario[] = [ // the fixture scripts five identical todo_write calls and pins BOTH reminder // tiers (gentle at 3, detailed at 5) as context/message in transcript and log. { name: 'repeat-tool-guard', hasModelTurn: true, recorded: false }, + // Authored replay: a root AGENTS.md pins the session prefix, then a read in + // nested/ discovers its narrower AGENTS.md as a raw, metadata-bearing + // context/message. The scenario-specific config keeps home/root discovery + // hermetic, and the resulting prefix needs its own pinned header class. + { + name: 'workspace-context', + hasModelTurn: true, + recorded: false, + overridden: true, + pinsHeader: true, + headerClass: 'workspace-context', + configPath: WORKSPACE_CONTEXT_CONFIG, + }, { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true }, { name: 'subagent-spawn', hasModelTurn: true, recorded: true, childSessions: 1 }, { name: 'subagent-multi', hasModelTurn: true, recorded: true, childSessions: 2 }, @@ -116,6 +131,17 @@ const SCENARIOS: Scenario[] = [ // tools:sdk section rides in the prompt, and the program's tool calls land as // tool/code-dispatch events. Each overlay composes and pins its own header class. { name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG }, + // A nested fs dispatch inside run_code discovers workspace instructions. The + // context/message must follow the outer result while retaining workspace + // provenance, which proves Code Mode carries deferred tool context end to end. + { + name: 'code-mode-workspace-context', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'code-workspace-context', + configPath: CODE_MODE_WORKSPACE_CONTEXT_CONFIG, + }, { name: 'both-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'both', configPath: BOTH_MODE_CONFIG }, // The default tree owns the single Permissions select. Snapshot mode starts // in danger-full-access so established fixtures stay runner-independent; diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/input.json b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/input.json new file mode 100644 index 0000000000..498816c5e4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?" } + ] +} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl new file mode 100644 index 0000000000..f721d51c77 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -0,0 +1,189 @@ +{"type":"session","version":0,"id":"65fbb8a6-624c-4d6a-bf5d-a7a7d14f2b49","createdAt":1783921765266,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26"} +{"type":"turn/start","seq":0,"time":1783921765269,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783921765269,"data":{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783921765275,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783921765275,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}]}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783921766287,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783921766287,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783921766483,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783921766519,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783921766520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783921766520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783921766520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":11,"time":1783921766537,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1783921766538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":13,"time":1783921766538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":14,"time":1783921766573,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} +{"type":"assistant/chunk","seq":15,"time":1783921766573,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":16,"time":1783921766574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":17,"time":1783921766574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reads"}}} +{"type":"assistant/chunk","seq":18,"time":1783921766598,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":19,"time":1783921766598,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":20,"time":1783921766599,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" called"}}} +{"type":"assistant/chunk","seq":21,"time":1783921766624,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} +{"type":"assistant/chunk","seq":22,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"/t"}}} +{"type":"assistant/chunk","seq":23,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} +{"type":"assistant/chunk","seq":24,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":25,"time":1783921766654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":26,"time":1783921766655,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":27,"time":1783921766684,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} +{"type":"assistant/chunk","seq":28,"time":1783921766717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":29,"time":1783921766717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" hand"}}} +{"type":"assistant/chunk","seq":30,"time":1783921766717,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"shake"}}} +{"type":"assistant/chunk","seq":31,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} +{"type":"assistant/chunk","seq":32,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" based"}}} +{"type":"assistant/chunk","seq":33,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" on"}}} +{"type":"assistant/chunk","seq":34,"time":1783921766743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":35,"time":1783921766776,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":36,"time":1783921766776,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":37,"time":1783921766798,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":38,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":39,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":40,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":41,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":42,"time":1783921766799,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} +{"type":"assistant/chunk","seq":43,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":44,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":45,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":46,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":47,"time":1783921766845,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":48,"time":1783921766926,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":49,"time":1783921766926,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":50,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":51,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":52,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"code"}}} +{"type":"assistant/chunk","seq":53,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":54,"time":1783921766952,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":55,"time":1783921766978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":56,"time":1783921766978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":57,"time":1783921766978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" content"}}} +{"type":"assistant/chunk","seq":58,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":59,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":60,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":61,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":".read"}}} +{"type":"assistant/chunk","seq":62,"time":1783921767011,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":63,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" file"}}} +{"type":"assistant/chunk","seq":64,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":65,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":66,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":67,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"n"}}} +{"type":"assistant/chunk","seq":68,"time":1783921767050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"ested"}}} +{"type":"assistant/chunk","seq":69,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"/t"}}} +{"type":"assistant/chunk","seq":70,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"ask"}}} +{"type":"assistant/chunk","seq":71,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":72,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":73,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":74,"time":1783921767068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":75,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":" content"}}} +{"type":"assistant/chunk","seq":76,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":";"}}} +{"type":"assistant/chunk","seq":77,"time":1783921767095,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":78,"time":1783921767121,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":79,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single run_code program that reads a file called nested/task.txt and then answer the handshake question based on what's in that file. Let me start by reading the file."}}}} +{"type":"assistant/chunk","seq":80,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}}}} +{"type":"assistant/chunk","seq":81,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3195,"outputTokens":106,"cacheReadTokens":0,"reasoningTokens":43}}}} +{"type":"assistant/chunk","seq":82,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":83,"time":1783921767208,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that reads a file called nested/task.txt and then answer the handshake question based on what's in that file. Let me start by reading the file."},{"type":"tool-call","id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}],"usage":{"inputTokens":3195,"outputTokens":106,"cacheReadTokens":0,"reasoningTokens":43}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82],"surfaceOp":"append"} +{"type":"tool/call","seq":84,"time":1783921767208,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}} +{"type":"tool/code-dispatch","seq":85,"time":1783921767270,"data":{"parentCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","subCallId":"call_00_6APApmaKLRDlXKMdIcWL5139:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"resultSummary":"./nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}} +{"type":"tool/result","seq":86,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[84],"surfaceOp":"append"} +{"type":"context/message","seq":87,"time":1783921767272,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"envelope":"raw","meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} +{"type":"step/end","seq":88,"time":1783921767272,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":89,"time":1783921767272,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":90,"time":1783921768339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":91,"time":1783921768340,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":92,"time":1783921768466,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} +{"type":"assistant/chunk","seq":93,"time":1783921768474,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"/t"}}} +{"type":"assistant/chunk","seq":94,"time":1783921768500,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} +{"type":"assistant/chunk","seq":95,"time":1783921768501,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":96,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" told"}}} +{"type":"assistant/chunk","seq":97,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":98,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":99,"time":1783921768535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":100,"time":1783921768564,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Touch"}}} +{"type":"assistant/chunk","seq":101,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":102,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":103,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":104,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" discover"}}} +{"type":"assistant/chunk","seq":105,"time":1783921768565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":106,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} +{"type":"assistant/chunk","seq":107,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workspace"}}} +{"type":"assistant/chunk","seq":108,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":109,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":110,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":111,"time":1783921768595,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":112,"time":1783921768621,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nested"}}} +{"type":"assistant/chunk","seq":113,"time":1783921768621,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"/"}}} +{"type":"assistant/chunk","seq":114,"time":1783921768647,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AG"}}} +{"type":"assistant/chunk","seq":115,"time":1783921768648,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENTS"}}} +{"type":"assistant/chunk","seq":116,"time":1783921768648,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".md"}}} +{"type":"assistant/chunk","seq":117,"time":1783921768648,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructions"}}} +{"type":"assistant/chunk","seq":118,"time":1783921768688,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" say"}}} +{"type":"assistant/chunk","seq":119,"time":1783921768703,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":120,"time":1783921768740,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":121,"time":1783921768740,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"When"}}} +{"type":"assistant/chunk","seq":122,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":123,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":124,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":125,"time":1783921768741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Code"}}} +{"type":"assistant/chunk","seq":126,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Mode"}}} +{"type":"assistant/chunk","seq":127,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workspace"}}} +{"type":"assistant/chunk","seq":128,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" hand"}}} +{"type":"assistant/chunk","seq":129,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"shake"}}} +{"type":"assistant/chunk","seq":130,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":131,"time":1783921768773,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} +{"type":"assistant/chunk","seq":132,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":133,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":134,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":135,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_M"}}} +{"type":"assistant/chunk","seq":136,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ODE"}}} +{"type":"assistant/chunk","seq":137,"time":1783921768790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CONT"}}} +{"type":"assistant/chunk","seq":138,"time":1783921768824,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"EXT"}}} +{"type":"assistant/chunk","seq":139,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":140,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} +{"type":"assistant/chunk","seq":141,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":142,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":143,"time":1783921768825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":144,"time":1783921768846,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\"\n\n"}}} +{"type":"assistant/chunk","seq":145,"time":1783921768846,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"So"}}} +{"type":"assistant/chunk","seq":146,"time":1783921768847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":147,"time":1783921768847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} +{"type":"assistant/chunk","seq":148,"time":1783921768847,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":149,"time":1783921768873,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":150,"time":1783921768874,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" hand"}}} +{"type":"assistant/chunk","seq":151,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"shake"}}} +{"type":"assistant/chunk","seq":152,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} +{"type":"assistant/chunk","seq":153,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":154,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":155,"time":1783921768903,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":156,"time":1783921768929,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_M"}}} +{"type":"assistant/chunk","seq":157,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ODE"}}} +{"type":"assistant/chunk","seq":158,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CONT"}}} +{"type":"assistant/chunk","seq":159,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"EXT"}}} +{"type":"assistant/chunk","seq":160,"time":1783921768930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":161,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"`."}}} +{"type":"assistant/chunk","seq":162,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":163,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":164,"time":1783921768963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" **"}}} +{"type":"assistant/chunk","seq":165,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Code"}}} +{"type":"assistant/chunk","seq":166,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" Mode"}}} +{"type":"assistant/chunk","seq":167,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" workspace"}}} +{"type":"assistant/chunk","seq":168,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" hand"}}} +{"type":"assistant/chunk","seq":169,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"shake"}}} +{"type":"assistant/chunk","seq":170,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"**"}}} +{"type":"assistant/chunk","seq":171,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} +{"type":"assistant/chunk","seq":172,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":173,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" `"}}} +{"type":"assistant/chunk","seq":174,"time":1783921769024,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":175,"time":1783921769046,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_M"}}} +{"type":"assistant/chunk","seq":176,"time":1783921769046,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ODE"}}} +{"type":"assistant/chunk","seq":177,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_CONT"}}} +{"type":"assistant/chunk","seq":178,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"EXT"}}} +{"type":"assistant/chunk","seq":179,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":180,"time":1783921769047,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"`"}}} +{"type":"assistant/chunk","seq":181,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The nested/task.txt told me to \"Touch this file to discover the nested workspace instruction.\" and the nested/AGENTS.md instructions say: \"When asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\"\n\nSo the answer to the handshake question is `CODE_MODE_CONTEXT_OK`."}}}} +{"type":"assistant/chunk","seq":182,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The **Code Mode workspace handshake** is: `CODE_MODE_CONTEXT_OK`"}}}} +{"type":"assistant/chunk","seq":183,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":277,"outputTokens":90,"cacheReadTokens":3200,"reasoningTokens":71}}}} +{"type":"assistant/chunk","seq":184,"time":1783921769101,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":185,"time":1783921769101,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The nested/task.txt told me to \"Touch this file to discover the nested workspace instruction.\" and the nested/AGENTS.md instructions say: \"When asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\"\n\nSo the answer to the handshake question is `CODE_MODE_CONTEXT_OK`."},{"type":"text","text":"The **Code Mode workspace handshake** is: `CODE_MODE_CONTEXT_OK`"}],"usage":{"inputTokens":277,"outputTokens":90,"cacheReadTokens":3200,"reasoningTokens":71}},"sourceEventSeqs":[90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184],"surfaceOp":"append"} +{"type":"step/end","seq":186,"time":1783921769101,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":187,"time":1783921769101,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl new file mode 100644 index 0000000000..fcf219e5ea --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl @@ -0,0 +1,137 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" program"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reads"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" called"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nested"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"/t"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ask"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" answer"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" hand"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"shake"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" question"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" based"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" on"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" start"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reading"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","title":"const content = await tools.read({ file_path: \"nested/task.txt\" });\nreturn content;","kind":"execute","status":"in_progress","rawInput":"const content = await tools.read({ file_path: \"nested/task.txt\" });\nreturn content;"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nested"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"/t"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ask"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" told"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Touch"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" discover"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nested"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" workspace"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nested"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"/"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AG"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENTS"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".md"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instructions"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" say"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"When"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" for"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Mode"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" workspace"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" hand"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"shake"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" answer"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_M"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_CONT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"EXT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nothing"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" else"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\"\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"So"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" answer"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" hand"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"shake"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" question"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_M"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_CONT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"EXT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" **"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Code"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" Mode"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" workspace"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" hand"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"shake"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"**"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_M"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ODE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_CONT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"EXT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_OK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.golden.md new file mode 100644 index 0000000000..65b354efd1 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.golden.md @@ -0,0 +1,157 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +## Writing code for run_code + +Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: + +- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable. +- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue. +- Calls execute sequentially, even under `Promise.all`. +- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. + +The available tools: + +```ts +declare const tools: { + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ + bash(args: { + /** The bash command to execute. */ + command: string; + /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; "git status" → "Show working tree status"; "npm install" → "Install package dependencies". */ + description: string; + /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */ + timeoutMs?: number; + /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */ + workdir?: string; + /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */ + run_in_background?: boolean; + /** The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval. */ + sandbox_permissions?: "workspace-write" | "danger-full-access"; + /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ + justification?: string; + }): Promise; + /** Edit an existing UTF-8 text file by replacing literal text. */ + edit(args: { + /** Path to edit, resolved by the filesystem backend. */ + file_path: string; + /** Literal text to replace. Must match exactly. */ + old_string: string; + /** Literal replacement text. Use an empty string to delete the match. */ + new_string: string; + /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ + replace_all?: boolean; + }): Promise; + /** Read a UTF-8 text file and return line-numbered content. */ + read(args: { + /** Path to read, resolved by the filesystem backend. */ + file_path: string; + /** 1-based first line to return. Defaults to 1. */ + offset?: number; + /** Maximum number of lines to return. Defaults to 2000. */ + limit?: number; + }): Promise; + /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ + skill(args: { + /** The exact skill name from the available skills list. */ + name: string; + }): Promise; + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ + subagent(args: { + /** A short (3-5 word) description of the delegated task, for display. */ + description: string; + /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ + prompt: string; + /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ + run_in_background?: boolean; + }): Promise; + /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ + subagent_fork(args: { + /** A short (3-5 word) description of the delegated task, for display. */ + description: string; + /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ + prompt: string; + /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ + run_in_background?: boolean; + }): Promise; + /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ + task_kill(args: { + /** Task id returned by the tool that started the background work. */ + task_id: string; + /** Optional short reason, recorded in the log and forwarded to the task. */ + reason?: string; + }): Promise; + /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ + task_list(args: Record): Promise; + /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */ + task_output(args: { + /** Task id returned by the tool that started the background work. */ + task_id: string; + /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */ + wait?: boolean; + /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ + timeout_ms?: number; + }): Promise; + /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ + todo_write(args: { + /** The COMPLETE task list, replacing any previous list. */ + todos: ({ + /** What the task is — a short imperative line. */ + content: string; + /** pending (not started) | in_progress (now) | completed (done). */ + status: "pending" | "in_progress" | "completed"; + })[]; + }): Promise; + /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ + workflow(args: { + /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ + script: string; + /** The workflow identity block (plain JSON — never code). */ + meta: { + /** Short kebab-case workflow name. */ + name: string; + /** One-line description of what the workflow does. */ + description: string; + /** Optional guidance on when this workflow applies. */ + whenToUse?: string; + /** Optional phase declarations matched by phase() calls. */ + phases?: { + /** The phase title phase() calls match by exact string. */ + title: string; + /** Optional one-line description of the phase. */ + detail?: string; + /** Optional model override this phase is expected to use. */ + model?: string; + }[]; + }; + /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ + args?: Record; + }): Promise; + /** Create or fully replace a UTF-8 text file. */ + write(args: { + /** Path to write, resolved by the filesystem backend. */ + file_path: string; + /** Full UTF-8 text content to write. */ + content: string; + }): Promise; +} +``` diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/tool-schemas.golden.json new file mode 100644 index 0000000000..61875ceb85 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/tool-schemas.golden.json @@ -0,0 +1,21 @@ +{ + "initial": [ + { + "name": "run_code", + "description": "Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.", + "parameters": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "The program: the body of an async TypeScript function." + } + }, + "required": [ + "code" + ] + } + } + ], + "deltas": [] +} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/workspace/AGENTS.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/workspace/AGENTS.md new file mode 100644 index 0000000000..b23c110ef6 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/workspace/AGENTS.md @@ -0,0 +1 @@ +Workspace snapshot root instruction. diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/workspace/nested/AGENTS.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/workspace/nested/AGENTS.md new file mode 100644 index 0000000000..1f71a5f827 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/workspace/nested/AGENTS.md @@ -0,0 +1 @@ +When asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else. diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/workspace/nested/task.txt b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/workspace/nested/task.txt new file mode 100644 index 0000000000..28806bb825 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/workspace/nested/task.txt @@ -0,0 +1 @@ +Touch this file to discover the nested workspace instruction. diff --git a/examples/acp-agent/tests/snapshots/workspace-context/input.json b/examples/acp-agent/tests/snapshots/workspace-context/input.json new file mode 100644 index 0000000000..94fd9dae92 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workspace-context/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Read nested/task.txt with the read tool, then reply DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/replay.override.json b/examples/acp-agent/tests/snapshots/workspace-context/replay.override.json new file mode 100644 index 0000000000..ef70491338 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workspace-context/replay.override.json @@ -0,0 +1,22 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_workspace_read", "name": "read", "argumentsDelta": "{\"file_path\":\"nested/task.txt\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_workspace_read", "name": "read", "arguments": "{\"file_path\":\"nested/task.txt\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "DONE" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "DONE" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl new file mode 100644 index 0000000000..42e6cc58ea --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -0,0 +1,24 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":1783778297065,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783778297069,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783778297070,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}]}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"read","argumentsDelta":"{\"file_path\":\"nested/task.txt\"}"}}} +{"type":"assistant/chunk","seq":6,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":8,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":1783778297070,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":1783778297070,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} +{"type":"tool/result","seq":11,"time":1783778297072,"data":{"turn":1,"step":1,"callId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"context/message","seq":12,"time":1783778297072,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"envelope":"raw","meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} +{"type":"step/end","seq":13,"time":1783778297072,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":14,"time":1783778297072,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":15,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":16,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":17,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":18,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":20,"time":1783778297073,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"step/end","seq":21,"time":1783778297073,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":22,"time":1783778297073,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl new file mode 100644 index 0000000000..f99eb73eef --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl @@ -0,0 +1,6 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_workspace_read","title":"Read nested/task.txt","kind":"read","status":"in_progress","locations":[{"path":"nested/task.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_workspace_read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.golden.md new file mode 100644 index 0000000000..b1fc71924b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.golden.md @@ -0,0 +1,21 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.golden.json new file mode 100644 index 0000000000..da5e23216c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.golden.json @@ -0,0 +1,348 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "deltas": [] +} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/workspace/.dsh-project b/examples/acp-agent/tests/snapshots/workspace-context/workspace/.dsh-project new file mode 100644 index 0000000000..8ce6fed8d7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workspace-context/workspace/.dsh-project @@ -0,0 +1 @@ +snapshot root marker diff --git a/examples/acp-agent/tests/snapshots/workspace-context/workspace/AGENTS.md b/examples/acp-agent/tests/snapshots/workspace-context/workspace/AGENTS.md new file mode 100644 index 0000000000..a66cf16a13 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workspace-context/workspace/AGENTS.md @@ -0,0 +1 @@ +Root snapshot instruction. diff --git a/examples/acp-agent/tests/snapshots/workspace-context/workspace/nested/AGENTS.md b/examples/acp-agent/tests/snapshots/workspace-context/workspace/nested/AGENTS.md new file mode 100644 index 0000000000..862c12a235 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workspace-context/workspace/nested/AGENTS.md @@ -0,0 +1 @@ +Nested snapshot instruction. diff --git a/examples/acp-agent/tests/snapshots/workspace-context/workspace/nested/task.txt b/examples/acp-agent/tests/snapshots/workspace-context/workspace/nested/task.txt new file mode 100644 index 0000000000..39e2106a6f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workspace-context/workspace/nested/task.txt @@ -0,0 +1 @@ +snapshot task diff --git a/examples/acp-agent/workspace-context.cordis.snapshot.yml b/examples/acp-agent/workspace-context.cordis.snapshot.yml new file mode 100644 index 0000000000..d06620232d --- /dev/null +++ b/examples/acp-agent/workspace-context.cordis.snapshot.yml @@ -0,0 +1,36 @@ +# Keyless replay counterpart of workspace-context.cordis.yml. Patches do not +# compose across includes, so this applies the scenario config and model swap +# directly to the live tree. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 + dshHome: !!js process.cwd() + '/.dsh' + projectRootMarkers: + - .dsh-project + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + - id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/workspace-context.cordis.yml b/examples/acp-agent/workspace-context.cordis.yml new file mode 100644 index 0000000000..751b09c85e --- /dev/null +++ b/examples/acp-agent/workspace-context.cordis.yml @@ -0,0 +1,31 @@ +# Workspace-context snapshot overlay: keep project-root and user-global +# discovery inside the scenario's temporary cwd. The app config patch replaces +# the whole base config, so the base fields are restated verbatim. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + workspaceContext: + maxBytes: 65536 + dshHome: !!js process.cwd() + '/.dsh' + projectRootMarkers: + - .dsh-project + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + - id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' diff --git a/examples/coding-agent/code-mode.cordis.yml b/examples/coding-agent/code-mode.cordis.yml index 5d7198124c..a6f262457a 100644 --- a/examples/coding-agent/code-mode.cordis.yml +++ b/examples/coding-agent/code-mode.cordis.yml @@ -14,6 +14,8 @@ model: deepseek-v4-flash resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 tools: mode: code welcome: 'code-mode agent ready. Give it a multi-tool task.' diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 61e389ebc5..f4e94788c8 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -1,6 +1,6 @@ # REPL agent with swappable DeepSeek and local-bash backends. `dsh-stdio-demo` -# supplies the agent spine, generic task controls, logging, JSONL persistence, -# readline UI, and `main` agent. +# supplies the agent spine, workspace instructions, generic task controls, +# logging, JSONL persistence, readline UI, and `main` agent. # HMR remains a leaf because it requires Loader internals; `demo:repl` passes # `--expose-internals`. The app bin loads the gitignored root `.env`; this file # reads `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` through `!!js`. @@ -37,6 +37,8 @@ # under ./.sessions); unset starts a fresh session each run. resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 welcome: 'agent REPL ready. Give it a coding task.' # Keep the persona to identity and behavior; tool plugins own tool guidance. # The loop resolves {{model}} from this agent's configuration. diff --git a/examples/coding-agent/tests/code-mode.e2e.ts b/examples/coding-agent/tests/code-mode.e2e.ts index 3f6e109e3f..f60caac634 100644 --- a/examples/coding-agent/tests/code-mode.e2e.ts +++ b/examples/coding-agent/tests/code-mode.e2e.ts @@ -1,10 +1,10 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' @@ -14,6 +14,9 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import * as WorkspaceContext from '@deepseek-ai/dsh-workspace-context' /** * With-key Code Mode proof: a real model receives only `run_code`, composes two @@ -23,6 +26,7 @@ import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' const PERSONA = 'You are coding-agent. You work by writing TypeScript programs for run_code: ' + 'batch related tool work into one program and print or return ONLY the findings that matter.' +const WORKSPACE_PROBE = 'dragonfruit-8675309' let ctx: Context | undefined let workdir: string | undefined @@ -52,6 +56,22 @@ async function codeModeHarness(cwd: string): Promise { return harness } +async function workspaceCodeModeHarness(): Promise { + const harness = new Context() + await harness.plugin(LlmService) + await harness.plugin(SessionStore) + await harness.plugin(SystemPrompt, { persona: PERSONA }) + await harness.plugin(ToolRegistry, { mode: 'code' }) + await harness.plugin(AgentRegistry) + await harness.plugin(LocalFileSystem, { cwd: '/' }) + await harness.plugin(ToolFs) + await harness.plugin(WorkspaceContext, { maxBytes: 65536 }) + await harness.plugin(AgentLoop, { agents: [] }) + await harness.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) + await harness.plugin(WorkerCodeRuntime, {}) + return harness +} + function waitForIdle(harness: Context, agent: ReactLoopAgent): Promise { return new Promise((resolve) => { const dispose = harness.on('agent/status', (subject, status) => { @@ -107,4 +127,43 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p expect(finalText).toContain('alpha-7') expect(finalText).toContain('beta-9') }, 180_000) + + it('delivers nested workspace instructions discovered by an fs sub-call after the outer result', async () => { + workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-workspace-e2e-')) + await mkdir(join(workdir, '.git'), { recursive: true }) + await mkdir(join(workdir, 'pkg/deep'), { recursive: true }) + await writeFile(join(workdir, 'pkg/AGENTS.md'), `If asked for the Code Mode workspace handshake, reply with exactly ${WORKSPACE_PROBE} and nothing else.\n`) + await writeFile(join(workdir, 'pkg/deep/task.txt'), 'Touch this file to discover the nested instructions.\n') + ctx = await workspaceCodeModeHarness() + const handle = await ctx.agents.create({ + agentId: AgentId('e2e-code-mode-workspace'), + sessionId: SessionId('e2e-code-mode-workspace-session'), + meta: { cwd: workdir }, + agentOptions: { model: 'deepseek-v4-flash' }, + }) + + handle.agent.send([{ + type: 'text', + text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?', + }]) + await waitForIdle(ctx, handle.agent as ReactLoopAgent) + + const events: SessionEvent[] = [...handle.agent.session.events] + const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') + const outerResult = events.find(event => event.type === 'tool/result') + const workspaceContext = events.find(event => event.type === 'context/message' + && typeof event.data.meta === 'object' + && event.data.meta !== null + && !Array.isArray(event.data.meta) + && event.data.meta.kind === 'workspace-instructions') + expect(dispatch).toBeDefined() + expect(outerResult).toBeDefined() + expect(workspaceContext).toBeDefined() + expect(workspaceContext!.seq).toBeGreaterThan(outerResult!.seq) + const finalMessage = events.findLast(event => event.type === 'assistant/message') + const answer = finalMessage?.type === 'assistant/message' + ? finalMessage.data.content.filter(block => block.type === 'text').map(block => block.text).join('') + : '' + expect(answer).toContain(WORKSPACE_PROBE) + }, 180_000) }) diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 8cd989e9e4..ff8c5feee1 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -23,7 +23,7 @@ - deepseek-v4-pro - deepseek-v4-flash -# Local bash executor for agent-core's tool-bash schema — gives the agent an +# Local bash executor for agent-spine-demo's tool-bash schema — gives the agent an # ordinary tool whose calls make the mounted listeners observably fire. - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -54,6 +54,8 @@ model: deepseek-v4-flash resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 welcome: 'cordis-agent ready. Ask it to inspect its runtime, mount a listener, or invent a tool for itself.' persona: | You are cordis-agent, a self-referential harness demo powered by the diff --git a/examples/echo-agent/composition.md b/examples/echo-agent/composition.md index 5160bd20b7..9c9f04cb50 100644 --- a/examples/echo-agent/composition.md +++ b/examples/echo-agent/composition.md @@ -16,6 +16,8 @@ flowchart LR cfg --> plugin_echo_echo_tool plugin_echo_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_echo_bash + plugin_echo_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] + cfg --> plugin_echo_fs_local plugin_echo_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-demo"] cfg --> plugin_echo_stdio_agent plugin_echo_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] @@ -33,6 +35,7 @@ flowchart LR | `mock-llm` | `./src/mock-llm.ts` | | `echo-tool` | `./src/echo-tool.ts` | | `bash` | `@deepseek-ai/dsh-bash-local` | +| `fs-local` | `@deepseek-ai/dsh-fs-local` | | `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` | Source config: [`examples/echo-agent/cordis.yml`](cordis.yml). diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml index fbf998e770..a7ada39115 100644 --- a/examples/echo-agent/cordis.yml +++ b/examples/echo-agent/cordis.yml @@ -16,12 +16,19 @@ - id: echo-tool name: './src/echo-tool.ts' -# Local bash executor: agent-core ships the `tool-bash` consumer schema, so the +# Local bash executor: agent-spine-demo ships the `tool-bash` consumer schema, so the # leaf provides the executor it runs on (the echo demo doesn't drive bash, but # the tool is part of the shared spine). - id: bash name: '@deepseek-ai/dsh-bash-local' +# Local filesystem provider for agent-spine-demo's workspace-context loader. This +# does not expose model-facing read/write/edit tools in the echo demo. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + # The app pre-creates `main` on the mock model and supplies logging, persistence, and readline UI. - id: stdio-agent name: '@deepseek-ai/dsh-stdio-demo' @@ -30,3 +37,5 @@ persona: 'You are echo-agent, a demo agent.' welcome: 'echo-agent ready. Type a message ("echo " triggers the tool).' persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 diff --git a/knip.json b/knip.json index b978da3a76..90e1218098 100644 --- a/knip.json +++ b/knip.json @@ -66,6 +66,15 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/context/workspace-context": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/util/paths": { + "entry": ["tests/**/*.spec.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"], + "ignoreDependencies": ["cordis"] + }, "packages/web/web-search-exa": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/README.md b/packages/README.md index 8f1bf3dde2..c718f52d9f 100644 --- a/packages/README.md +++ b/packages/README.md @@ -16,7 +16,7 @@ Packages live at `packages///`; groups are containers, while names r | [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface | | [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface | | [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface | -| [`context/`](context/README.md) | Opt-in request-context enrichment | Product — stable surface | +| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | | [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface | | [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, the worker-thread engine, and the model-facing `workflow` tool | Product — stable surface | @@ -31,8 +31,8 @@ Packages live at `packages///`; groups are containers, while names r | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface | | [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/ACP/JSON-RPC bins) the leaves load | Support — example infra | -| [`support/`](support/README.md) | Support infrastructure (invariants, replay, Loader smokes) | Support — lower compatibility expectations | -| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded` primitive) | Support — small, stable, harness-dep-free | +| [`support/`](support/README.md) | Support infrastructure (invariants, replay, Loader smokes, subagent mock) | Support — lower compatibility expectations | +| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, path helpers) | Support — small, stable, harness-dep-free | Groups distinguish product API from support infrastructure. New packages join an existing group; a new group updates its README and this table. diff --git a/packages/context/README.md b/packages/context/README.md index 0045c6629c..374fb96374 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -1,7 +1,10 @@ -# context/ — optional request context +# context/ — request-context extensions -Opt-in plugins that add bounded model-visible request context without defining a tool or service. The default `dsh-agent-spine-demo` bundle excludes them. +Product plugins that add bounded model-visible request context without defining a tool or service. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in. | Package | Role | ctx key | |---|---|---| | `time-context/` | Current time and elapsed-time system-prompt context | (none) | +| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) | + +The [`workspace-context` decision record](../../docs/rfc/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split. diff --git a/packages/context/time-context/tests/fixtures/cordis.yml b/packages/context/time-context/tests/fixtures/cordis.yml index da18fc11df..a84985ef5e 100644 --- a/packages/context/time-context/tests/fixtures/cordis.yml +++ b/packages/context/time-context/tests/fixtures/cordis.yml @@ -15,3 +15,4 @@ persona: 'Test the time-context plugin.' welcome: 'time-context e2e ready.' persistenceRoot: './.sessions' + workspaceContext: false diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md new file mode 100644 index 0000000000..910c1cfdac --- /dev/null +++ b/packages/context/workspace-context/README.md @@ -0,0 +1,140 @@ +# @deepseek-ai/dsh-workspace-context + +Per-session workspace instruction loading for `AGENTS.md`-compatible files. The plugin freezes the initial user-global and project instruction chain into the request prefix, then discovers nested files and reports later changes or removals through durable context messages after successful filesystem tool calls. + +## Lifecycle + +The baseline is composed once per agent-loop instance on `agent/session-prefix`. It reads `$DSH_HOME/AGENTS.md` followed by one configured instruction candidate in each directory from the project root to `agent.session.header.cwd`. The prefix is placed before all derived history, recorded in `EpochHeader.messagePrefix`, and reused verbatim for that loop instance. Because the plugin prepends its contribution before delegating, a later-registered skills catalog appears after workspace instructions. + +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. Once `lstat` identifies the winning regular-file candidate, a later resolve/stat failure makes that scope temporarily unavailable instead of falling through to a lower-priority name. 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 + +Baseline instructions are request-only user-role prefix messages framed with the familiar system-reminder pattern: + +```md + +The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. + +Instructions from: ~/.dsh/AGENTS.md + +... + +Instructions from: AGENTS.md + +... + +``` + +Newly reached scopes use a durable raw `context/message`: + +```md + +Additional instructions from: packages/app/AGENTS.md + +These instructions apply to work under `packages/app`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions. + +... + +``` + +A same-file edit starts with `Updated instructions from: ` and says to use the new content instead of the previously loaded content. A candidate switch additionally names the old path. When no candidate remains, the message is `Instructions removed: ` followed by `The previously loaded instructions from this file no longer apply.` Literal `` text inside an instruction file is escaped so file content cannot close the plugin-owned frame. + +The core `context/message` envelope is disabled for these messages because the plugin already owns the complete `` framing. This is caller-selected with `envelope: 'raw'`; ordinary injected context still receives the canonical `` envelope. + +## 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 present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives first because a later tool aborted the step and the loop discarded its context buffer, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. 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. A per-session, per-scope metadata cache stores only `{ path, version, digest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only. + +The frozen baseline itself is not rewritten mid-instance. Its initial path/digest map is retained as comparison state; the next successful filesystem touch appends any baseline replacement or removal. A resumed loop recomposes the current baseline and also reconciles still-visible dynamic scopes during prefix composition. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop composes its prefix. + +## Configuration + +```ts +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. `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 render budget disables both baseline and dynamic loading; configured `maxSourceBytes` must be a positive integer. + +## 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`. + +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 cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata. + +## Model Experience + +### Baseline session prefix + +**What the model sees**: At the first request of each loop instance, the model receives one user-role prefix message containing the bounded user-global and project instruction chain in broad-to-specific order. + +**Token effect**: The rendered baseline is frozen and resent on every request in that loop instance. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens. + +#### Baseline instruction template + +```markdown + +The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. + +Instructions from: ~/.dsh/AGENTS.md + + + +Instructions from: AGENTS.md + + + +``` + +### Newly discovered scope context + +**What the model sees**: After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained raw `context/message` with the newly applicable instruction file. + +**Token effect**: Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result. + +#### Additional instruction template + +```markdown + +Additional instructions from: packages/app/AGENTS.md + +These instructions apply to work under `packages/app`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions. + + + +``` + +### Changed or removed instruction context + +**What the model sees**: A changed file produces `Updated instructions from: ` plus its replacement content; a candidate switch also names the previous path. A removed final candidate produces the removal notice below. + +**Token effect**: Each confirmed change or removal is one retained history message bounded by `maxBytes`. Provider failures add no message, and an update omitted by the budget remains eligible for a later filesystem touch. + +#### Removal notice + +```markdown + +Instructions removed: packages/app/AGENTS.md + +The previously loaded instructions from this file no longer apply. + +``` + +## Known Limitations and Deferred Work + +- **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam. +- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop recomposes its prefix. +- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; same-directory names such as `CLAUDE.local.md` require explicit `instructionFileCandidates` configuration. +- **Instruction content is bounded, not summarized** — over-budget broad files are omitted and the most-specific file may be truncated; the plugin never asks a model to compress instruction prose. diff --git a/packages/context/workspace-context/package.json b/packages/context/workspace-context/package.json new file mode 100644 index 0000000000..7f704c838a --- /dev/null +++ b/packages/context/workspace-context/package.json @@ -0,0 +1,51 @@ +{ + "name": "@deepseek-ai/dsh-workspace-context", + "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/context/workspace-context/src/config.ts b/packages/context/workspace-context/src/config.ts new file mode 100644 index 0000000000..c4bdd663c7 --- /dev/null +++ b/packages/context/workspace-context/src/config.ts @@ -0,0 +1,82 @@ +/** + * Configuration normalization for workspace instruction discovery and rendering. + * + * @module @deepseek-ai/dsh-workspace-context/config + */ + +import z from 'schemastery' +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. */ +export interface Config { + /** Harness home containing the fixed user-global `AGENTS.md`; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string + /** Directory entries that identify the project root while walking upward from the session cwd. */ + 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[] +} + +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]), +}) + +/** Normalized instruction discovery configuration. */ +export interface ResolvedDiscoveryConfig { + dshHome: string + projectRootMarkers: string[] + instructionFileCandidates: string[] +} + +/** Normalized configuration used by discovery and reconciliation. */ +export interface ResolvedConfig extends ResolvedDiscoveryConfig { + maxBytes: number + maxSourceBytes: number +} + +/** + * Resolve defaults, the harness home, and valid same-directory candidates. + * @param config - user-facing plugin configuration. + * @returns normalized runtime configuration. + */ +export function resolveConfig(config: Config): ResolvedConfig { + return { + ...resolveDiscoveryConfig(config), + maxBytes: config.maxBytes, + maxSourceBytes: config.maxSourceBytes ?? DEFAULT_MAX_SOURCE_BYTES, + } +} + +/** + * Resolve the subset of configuration used before instruction content is rendered. + * @param config - optional discovery controls. + * @returns normalized home, root markers, and instruction candidates. + */ +export function resolveDiscoveryConfig( + config: Pick, +): ResolvedDiscoveryConfig { + return { + dshHome: resolveDshHome(config.dshHome), + projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS], + instructionFileCandidates: resolveInstructionFileCandidates(config.instructionFileCandidates), + } +} + +function resolveInstructionFileCandidates(candidates: string[] | undefined): string[] { + return (candidates ?? [...DEFAULT_INSTRUCTION_FILE_CANDIDATES]).filter(candidate => ( + !RESERVED_PATH_SEGMENTS.has(candidate) && !/[\\/]/.test(candidate) + )) +} diff --git a/packages/context/workspace-context/src/digest.ts b/packages/context/workspace-context/src/digest.ts new file mode 100644 index 0000000000..4568371277 --- /dev/null +++ b/packages/context/workspace-context/src/digest.ts @@ -0,0 +1,16 @@ +/** + * Content identity for workspace instruction duplicate suppression. + * + * @module @deepseek-ai/dsh-workspace-context/digest + */ + +import { createHash } from 'node:crypto' + +/** + * Compute the content identity used across instruction loading and session state. + * @param content - exact UTF-8 instruction text. + * @returns lowercase SHA-1 digest in hexadecimal form. + */ +export function instructionContentSha1(content: string): string { + return createHash('sha1').update(content).digest('hex') +} diff --git a/packages/context/workspace-context/src/files.ts b/packages/context/workspace-context/src/files.ts new file mode 100644 index 0000000000..feb6304b4c --- /dev/null +++ b/packages/context/workspace-context/src/files.ts @@ -0,0 +1,473 @@ +/** + * Instruction-file discovery and bounded, abort-aware provider reads. + * + * @module @deepseek-ai/dsh-workspace-context/files + */ + +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, FsVersion } from '@deepseek-ai/dsh-fs' +import { assertNever } from '@deepseek-ai/dsh-llm' +import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths' +import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts' +import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts' + +/** An instruction candidate identified by absolute and model-facing paths. */ +export interface InstructionFile { + absolutePath: string + displayPath: string +} + +/** An instruction file whose UTF-8 content was read successfully. */ +export interface LoadedInstructionFile extends InstructionFile { + content: string + /** Provider freshness token when the file was loaded through `ctx.fs`. */ + version?: FsVersion +} + +interface DiscoveredInstructionFile extends InstructionFile { + target?: FsTarget + size?: number + version?: FsVersion +} + +/** Provider metadata for a winning scope candidate before its content is read. */ +export interface ProbedInstructionFile extends InstructionFile { + target: FsTarget + version: FsVersion + size?: number +} + +interface DiscoverOptions { + cwd: string + dshHome?: string + projectRootMarkers?: string[] + instructionFileCandidates?: string[] + signal?: AbortSignal +} + +interface LoadOptions extends DiscoverOptions { + maxBytes: number + maxSourceBytes?: number +} + +/** Rendered baseline plus the files that survived byte budgeting. */ +export interface RenderedInstructionSet { + rendered: RenderedWorkspaceContext + included: LoadedInstructionFile[] +} + +/** Tri-state scope probe that distinguishes confirmed absence from provider failure. */ +export type ScopeInstructionProbe = + | { kind: 'present'; file: ProbedInstructionFile } + | { kind: 'absent' } + | { kind: 'unavailable' } + +interface StatFileInfo { + target?: FsTarget + size?: number + version?: FsVersion +} + +type StatFileProbe = + | { kind: 'present'; info: StatFileInfo } + | { kind: 'absent' } + | { kind: 'unavailable' } + +function signalOptions(signal?: AbortSignal): { signal: AbortSignal } | undefined { + return signal === undefined ? undefined : { signal } +} + +function isMissingPathError(error: unknown): boolean { + return error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR') +} + +async function nodeStatFile(path: string, signal?: AbortSignal): Promise { + try { + signal?.throwIfAborted() + const info = await lstat(path) + signal?.throwIfAborted() + if (!info.isFile()) return { kind: 'absent' } + return { kind: 'present', info: { size: info.size } } + } catch (error: unknown) { + signal?.throwIfAborted() + return isMissingPathError(error) ? { kind: 'absent' } : { kind: 'unavailable' } + } +} + +async function fsStatFile( + path: string, + fileSystem: FileSystem, + signal?: AbortSignal, +): Promise { + // TODO(instruction-symlink-race): replace this lstat -> resolve -> read + // protocol, including probeScopeInstruction below, with a provider-owned + // atomic no-follow read so the final component cannot change after validation. + let pathInfo: FsPathInfo | undefined + try { + pathInfo = await fileSystem.lstat(path, undefined, signal) + signal?.throwIfAborted() + } catch { + signal?.throwIfAborted() + return { kind: 'unavailable' } + } + if (pathInfo?.type !== 'file') return { kind: 'absent' } + + try { + const target = await fileSystem.resolve(path, signalOptions(signal)) + signal?.throwIfAborted() + const info = await fileSystem.stat(target, signal) + signal?.throwIfAborted() + if (info?.type !== 'file') return { kind: 'unavailable' } + return { + kind: 'present', + info: { target, version: info.version, ...info.size === undefined ? {} : { size: info.size } }, + } + } catch { + signal?.throwIfAborted() + return { kind: 'unavailable' } + } +} + +async function statFile( + path: string, + fileSystem?: FileSystem, + signal?: AbortSignal, +): Promise { + return fileSystem === undefined ? nodeStatFile(path, signal) : fsStatFile(path, fileSystem, signal) +} + +async function existsAsMarker(path: string, fileSystem?: FileSystem, signal?: AbortSignal): Promise { + if (fileSystem !== undefined) { + try { + const target = await fileSystem.resolve(path, signalOptions(signal)) + return await fileSystem.stat(target, signal) !== undefined + } catch { + signal?.throwIfAborted() + // TODO(root-marker-unavailable): preserve provider failure separately from + // absence and stop discovery; continuing upward can cross into an ancestor project. + return false + } + } + try { + signal?.throwIfAborted() + await stat(path) + signal?.throwIfAborted() + return true + } catch { + signal?.throwIfAborted() + return false + } +} + +/** + * Walk upward to the first directory containing a configured root marker. + * @param cwd - absolute session working directory where the walk begins. + * @param markers - child names that identify a project root. + * @param fileSystem - optional provider used instead of host filesystem probes. + * @param signal - cancellation for provider and host probes. + * @returns the discovered project root, or `cwd` when no marker exists. + */ +export async function findProjectRoot( + cwd: string, + markers: readonly string[], + fileSystem?: FileSystem, + signal?: AbortSignal, +): Promise { + let current = resolve(cwd) + for (;;) { + for (const marker of markers) { + if (await existsAsMarker(join(current, marker), fileSystem, signal)) return current + } + const parent = dirname(current) + if (parent === current) return resolve(cwd) + current = parent + } +} + +/** + * Build the inclusive root-to-cwd directory chain. + * @param root - root directory expected to contain or equal `cwd`. + * @param cwd - most-specific directory in the chain. + * @returns directories ordered from broadest to most specific. + */ +export function ancestorChain(root: string, cwd: string): string[] { + const chain: string[] = [] + let current = resolve(cwd) + const resolvedRoot = resolve(root) + while (current !== resolvedRoot) { + chain.push(current) + const parent = dirname(current) + /* v8 ignore next -- discovery always supplies cwd or an ancestor root. */ + if (parent === current) break + current = parent + } + chain.push(resolvedRoot) + return chain.reverse() +} + +/** + * Find descendant directories crossed between a cwd and a touched file. + * @param root - session cwd that bounds nested discovery. + * @param touchedPath - absolute path or path relative to `root`. + * @returns descendant directories from shallowest through the touched file's parent. + */ +export function descendantDirsBetween(root: string, touchedPath: string): string[] { + const resolvedRoot = resolve(root) + const targetPath = isAbsolute(touchedPath) ? resolve(touchedPath) : resolve(resolvedRoot, touchedPath) + const targetDir = dirname(targetPath) + const rel = relative(resolvedRoot, targetDir) + if (rel.length === 0 || rel.startsWith('..') || isAbsolute(rel)) return [] + return ancestorChain(resolvedRoot, targetDir).slice(1) +} + +/** + * Convert an absolute instruction path to its project-root-relative display form. + * @param root - project root used as the display base. + * @param path - absolute path to display. + * @returns the root-relative path. + */ +export function relativeDisplay(root: string, path: string): string { + return relative(root, path) +} + +async function firstExistingInstructionFile( + dir: string, + root: string, + instructionFileCandidates: readonly string[], + fileSystem?: FileSystem, + signal?: AbortSignal, +): Promise { + for (const candidate of instructionFileCandidates) { + const path = join(dir, candidate) + const probe = await statFile(path, fileSystem, signal) + switch (probe.kind) { + case 'present': + return { + absolutePath: path, + displayPath: relativeDisplay(root, path), + ...probe.info, + } + case 'absent': + continue + case 'unavailable': + return undefined + /* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */ + default: + return assertNever(probe, 'StatFileProbe') + } + } + return undefined +} + +async function discoverInstructionFiles( + options: DiscoverOptions, + fileSystem?: FileSystem, +): Promise { + const config = resolveDiscoveryConfig(options) + const files: DiscoveredInstructionFile[] = [] + const seen = new Set() + const addFile = (file: DiscoveredInstructionFile): void => { + if (seen.has(file.absolutePath)) return + seen.add(file.absolutePath) + files.push(file) + } + + const userGlobal = join(config.dshHome, 'AGENTS.md') + const userGlobalProbe = await statFile(userGlobal, fileSystem, options.signal) + switch (userGlobalProbe.kind) { + case 'present': + addFile({ + absolutePath: userGlobal, + displayPath: userGlobalDisplayPath(config.dshHome), + ...userGlobalProbe.info, + }) + break + case 'absent': + case 'unavailable': + break + /* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */ + default: + assertNever(userGlobalProbe, 'StatFileProbe') + } + + const cwd = resolve(options.cwd) + 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, options.signal) + if (file !== undefined) addFile(file) + } + return files +} + +/** + * Discover host-visible user-global and root-to-cwd instruction candidates. + * @param options - cwd, home, root marker, and candidate configuration. + * @returns de-duplicated instruction paths in model precedence order. + */ +export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise { + return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath })) +} + +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, + maxSourceBytes: number, + fileSystem?: FileSystem, + signal?: AbortSignal, +): Promise { + // TODO(total-instruction-read-bound): enforce an aggregate source budget + // across a complete baseline or reconciliation batch; the render budget is + // applied only after every accepted file has been read under this per-file cap. + signal?.throwIfAborted() + if (file.size !== undefined && file.size > maxSourceBytes) return undefined + try { + 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 + } +} + +/** + * Discover, read, and render the baseline instruction chain. + * @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. + */ +export async function loadBaselineInstructions( + options: LoadOptions, + fileSystem?: FileSystem, +): Promise { + return (await loadBaselineInstructionSet(options, fileSystem))?.rendered +} + +/** + * Load a baseline together with the files retained after rendering. + * @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. + */ +export async function loadBaselineInstructionSet( + options: LoadOptions, + fileSystem?: FileSystem, +): Promise { + const config = resolveConfig(options) + if (config.maxBytes <= 0 || !Number.isFinite(config.maxBytes)) return undefined + 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 readBounded(file, config.maxSourceBytes, fileSystem, options.signal) + if (content !== undefined) { + loaded.push({ + absolutePath: file.absolutePath, + displayPath: file.displayPath, + content, + ...file.version === undefined ? {} : { version: file.version }, + }) + } + } + if (loaded.length === 0) return undefined + const rendered = renderWorkspaceContext(loaded, { maxBytes: config.maxBytes }) + const omitted = new Set(rendered.omitted.map(file => file.absolutePath)) + return { rendered, included: loaded.filter(file => !omitted.has(file.absolutePath)) } +} + +/** + * Probe the current first-winning instruction candidate for one logical scope. + * @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 fileSystem - provider used for no-follow probing. + * @param signal - cancellation for provider probes. + * @returns present metadata, confirmed absence, or temporary unavailability. + */ +export async function probeScopeInstruction( + scope: string, + projectRoot: string, + resolved: ResolvedConfig, + fileSystem: FileSystem, + signal?: AbortSignal, +): Promise { + const dir = scope === 'user-global' + ? resolved.dshHome + : scope === '.' ? projectRoot : join(projectRoot, scope) + const candidates = scope === 'user-global' ? ['AGENTS.md'] : resolved.instructionFileCandidates + for (const candidate of candidates) { + const absolutePath = join(dir, candidate) + let pathInfo: FsPathInfo | undefined + try { + 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, signalOptions(signal)) + info = await fileSystem.stat(target, signal) + } catch { + signal?.throwIfAborted() + return { kind: 'unavailable' } + } + if (info?.type !== 'file') return { kind: 'unavailable' } + const file: ProbedInstructionFile = { + absolutePath, + displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath), + target, + version: info.version, + ...info.size === undefined ? {} : { size: info.size }, + } + return { kind: 'present', file } + } + return { kind: 'absent' } +} + +/** + * Read one already-probed scope candidate under the configured source cap. + * @param file - winning provider candidate and its metadata snapshot. + * @param maxSourceBytes - maximum UTF-8 bytes accepted from the source. + * @param fileSystem - provider used for the streaming read. + * @param signal - cancellation for provider streaming. + * @returns loaded content with the probed version, or undefined when unavailable. + */ +export async function readScopeInstruction( + file: ProbedInstructionFile, + maxSourceBytes: number, + fileSystem: FileSystem, + signal?: AbortSignal, +): Promise { + const content = await readBounded(file, maxSourceBytes, fileSystem, signal) + if (content === undefined) return undefined + return { + absolutePath: file.absolutePath, + displayPath: file.displayPath, + content, + version: file.version, + } +} + +function userGlobalDisplayPath(dshHome: string): string { + return dshHome === resolve(defaultDshHome()) ? `${DEFAULT_DSH_HOME_DISPLAY}/AGENTS.md` : '$DSH_HOME/AGENTS.md' +} diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts new file mode 100644 index 0000000000..83bbfa9d26 --- /dev/null +++ b/packages/context/workspace-context/src/index.ts @@ -0,0 +1,173 @@ +/** + * Workspace instruction loader for AGENTS.md-compatible files. + * + * Baseline instructions are frozen into `agent/session-prefix`; successful fs + * tool touches reconcile nested, changed, and removed instructions through + * `tools/post-execute` for the next model request. Plugin lifecycle reads use + * the optional `ctx.fs` provider, so providerless products mount it as a no-op. + * + * @module @deepseek-ai/dsh-workspace-context + */ + +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, ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import { Config, resolveConfig, type ResolvedConfig } from './config.ts' +import { loadBaselineInstructionSet } from './files.ts' +import { + applyInstructionVersionUpdates, + baselineInstructionState, + commitPendingInstructionContexts, + dynamicInstructionContext, + name, + observeInstructionSessionEvent, + reconcileInstructionContext, + retainedInstructionVersionUpdates, + rollbackPendingInstructionChanges, + workspaceContextMessage, + type InstructionVersionCache, + type InstructionVersionUpdate, + type PendingInstructionChange, +} from './state.ts' +import type { WorkspaceInstructionChange } from './render.ts' + +export { Config, name } +export { + discoverBaselineInstructionFiles, + loadBaselineInstructions, +} from './files.ts' +export type { + InstructionFile, + LoadedInstructionFile, +} from './files.ts' +export { renderWorkspaceContext } from './render.ts' +export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts' + +export function apply(ctx: Context, config: Config): void { + const resolved: ResolvedConfig = resolveConfig(config) + const pendingNestedChanges = new WeakMap>() + const baselineInstructionStates = new WeakMap>() + const instructionVersions: InstructionVersionCache = new WeakMap() + const pendingVersionUpdates = new Map() + const pendingByParent = new Map() + + ctx.on('session/event', (session, event) => { + observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions) + }) + + 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') + if (fileSystem === undefined) return rest + /* v8 ignore next -- normal agents carry an absolute session cwd. */ + const cwd = agent.session.header.cwd ?? process.cwd() + const instructions = await loadBaselineInstructionSet({ + cwd, + dshHome: resolved.dshHome, + projectRootMarkers: resolved.projectRootMarkers, + maxBytes: resolved.maxBytes, + maxSourceBytes: resolved.maxSourceBytes, + instructionFileCandidates: resolved.instructionFileCandidates, + signal, + }, fileSystem) + const baseline = baselineInstructionState(instructions?.included ?? []) + baselineInstructionStates.set(agent.session, baseline.changes) + instructionVersions.set(agent.session, baseline.versions) + + const update = await reconcileInstructionContext( + agent, + resolved, + pendingNestedChanges, + baselineInstructionStates, + instructionVersions, + fileSystem, + { includeBaselineScopes: false, signal }, + ) + if (update !== undefined) { + agent.inject(update.context.content, { + source: update.context.source, + envelope: update.context.envelope, + meta: update.context.meta, + }) + applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions) + } + if (instructions === undefined || instructions.rendered.text.length === 0) return rest + return [workspaceContextMessage(instructions.rendered.text), ...rest] + }) + + ctx.on('tools/post-execute', async ( + exec: ToolExecution, + result: ToolExecutionResult, + next, + ): Promise => { + const downstream = await next() + // A downstream listener/policy blocked this call: the registry turns it + // into a final `isError` result, so treat it like a failed fs touch and + // load nothing. Reconciling here would surface workspace instructions from + // a call the pipeline rejected, violating the "successful fs tool touches" + // contract, and would advance the nested/baseline tracking state off a + // touch that never really happened. + if (downstream.kind === 'block') return downstream + const fileSystem = ctx.get('fs') + if (fileSystem === undefined) return downstream + const update = await dynamicInstructionContext( + exec.agent, + exec, + result, + resolved, + pendingNestedChanges, + baselineInstructionStates, + instructionVersions, + fileSystem, + ) + if (update === undefined) return downstream + pendingVersionUpdates.set(exec.token, update.versionUpdates) + return { + kind: 'accept', + ...downstream.content !== undefined ? { content: downstream.content } : {}, + additionalContexts: [update.context, ...downstream.additionalContexts ?? []], + } + }) + + ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => { + const ownVersionUpdates = pendingVersionUpdates.get(exec.token) ?? [] + pendingVersionUpdates.delete(exec.token) + 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 versionUpdates = retainedInstructionVersionUpdates(ownVersionUpdates, changes) + const staged = pendingByParent.get(exec.parent) + if (staged === undefined) pendingByParent.set(exec.parent, { agent: exec.agent, changes, versionUpdates }) + else { + staged.changes.push(...changes) + staged.versionUpdates.push(...versionUpdates) + } + 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 + const committed = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges) + const stagedVersionUpdates = staged?.versionUpdates ?? [] + const versionUpdates = retainedInstructionVersionUpdates( + [...stagedVersionUpdates, ...ownVersionUpdates], + committed, + ) + applyInstructionVersionUpdates(exec.agent.session, versionUpdates, instructionVersions) + }) +} diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts new file mode 100644 index 0000000000..34e427d0e3 --- /dev/null +++ b/packages/context/workspace-context/src/render.ts @@ -0,0 +1,255 @@ +/** + * Model-facing workspace instruction rendering within an explicit byte budget. + * + * @module @deepseek-ai/dsh-workspace-context/render + */ + +import { dirname } from 'node:path' +import type { InstructionFile, LoadedInstructionFile } from './files.ts' + +const SYSTEM_REMINDER_OPEN = '' +const SYSTEM_REMINDER_CLOSE = '' +const WORKSPACE_CONTEXT_INTRO = 'The following workspace instructions may be relevant to your work. ' + + 'Use them as guidance when applicable. More specific instructions take precedence over broader ones. ' + + 'They do not override system, developer, or direct user instructions.' +const COMPACT_WORKSPACE_CONTEXT_INTRO = 'Workspace instructions were omitted or truncated to fit the configured byte budget.' + +/** Byte-accounting record for one truncated instruction file. */ +export interface TruncatedInstruction { + displayPath: string + originalBytes: number + includedBytes: number +} + +/** Model-facing text plus omitted and truncated source records. */ +export interface RenderedWorkspaceContext { + text: string + omitted: InstructionFile[] + truncated: TruncatedInstruction[] +} + +/** Structured dynamic state persisted outside model-visible prompt prose. */ +export interface WorkspaceInstructionChange { + action: 'set' | 'replace' | 'remove' + scope: string + path: string + previousPath?: string + digest?: string +} + +/** One state transition paired with the content used to render it. */ +export interface ChangeRenderItem { + change: WorkspaceInstructionChange + file: LoadedInstructionFile +} + +interface RenderStyle { + intro: string + section(file: LoadedInstructionFile): string +} + +function byteLength(value: string): number { + return Buffer.byteLength(value, 'utf8') +} + +function truncateUtf8(value: string, maxBytes: number): string { + let truncated = Buffer.from(value, 'utf8').subarray(0, Math.max(0, maxBytes)).toString('utf8') + while (byteLength(truncated) > maxBytes) { + truncated = truncated.slice(0, -1) + } + return truncated +} + +function escapeInstructionContent(content: string): string { + // TODO(instruction-frame-paths): apply the same delimiter neutralization to + // every interpolated path, scope, and previous path; repository-controlled + // names can otherwise close the plugin-owned system-reminder frame. + return content.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>') +} + +function sectionText(file: LoadedInstructionFile): string { + return `Instructions from: ${file.displayPath}\n\n${escapeInstructionContent(file.content)}` +} + +/** + * Derive the logical instruction scope from a model-facing path. + * @param displayPath - project-relative or user-global instruction path. + * @returns `user-global`, `.`, or the containing project-relative directory. + */ +export function scopeForDisplayPath(displayPath: string): string { + if (displayPath === '~/.dsh/AGENTS.md' || displayPath === '$DSH_HOME/AGENTS.md') return 'user-global' + return dirname(displayPath) +} + +function additionalSectionText(file: LoadedInstructionFile): string { + const scope = scopeForDisplayPath(file.displayPath) + return [ + `Additional instructions from: ${file.displayPath}`, + '', + `These instructions apply to work under \`${scope}\`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.`, + '', + escapeInstructionContent(file.content), + ].join('\n') +} + +const BASELINE_RENDER_STYLE: RenderStyle = { intro: WORKSPACE_CONTEXT_INTRO, section: sectionText } + +function changedSectionText(item: ChangeRenderItem): string { + const { change, file } = item + if (change.action === 'set') return additionalSectionText(file) + if (change.action === 'remove') { + return `Instructions removed: ${change.path}\n\nThe previously loaded instructions from this file no longer apply.` + } + const description = change.previousPath === undefined + ? 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.' + : `The instructions previously loaded from \`${change.previousPath}\` no longer apply. Use the following content for \`${change.scope}\` instead.` + return [ + `Updated instructions from: ${change.path}`, + '', + description, + '', + escapeInstructionContent(file.content), + ].join('\n') +} + +/** + * Render one reconciliation batch and retain only transitions that fit. + * @param items - ordered state transitions and current file contents. + * @param maxBytes - maximum UTF-8 bytes allowed in the rendered batch. + * @returns bounded prompt text and the transitions actually represented by it. + */ +export function renderInstructionChanges( + items: ChangeRenderItem[], + maxBytes: number, +): { text: string; changes: WorkspaceInstructionChange[] } { + const byAbsolutePath = new Map(items.map(item => [item.file.absolutePath, item])) + const style: RenderStyle = { + intro: '', + section(file) { + const item = byAbsolutePath.get(file.absolutePath) + /* v8 ignore next -- the renderer receives exactly the files used to construct this map. */ + return item === undefined ? '' : changedSectionText({ ...item, file }) + }, + } + const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style) + const omitted = new Set(rendered.omitted.map(file => file.absolutePath)) + return { + text: rendered.text, + // TODO(rendered-change-proof): retain a transition only when its semantic + // notice survived rendering; a tiny compact budget can currently return + // unrelated notice text while still committing the full state transition. + changes: items.filter(item => !omitted.has(item.file.absolutePath)).map(item => item.change), + } +} + +function markerText(maxBytes: number, omitted: InstructionFile[], truncated: TruncatedInstruction[]): string { + if (omitted.length === 0 && truncated.length === 0) return '' + const parts: string[] = [] + if (omitted.length > 0) { + parts.push(`omitted ${omitted.map(file => file.displayPath).join(', ')}`) + } + if (truncated.length > 0) { + parts.push(`truncated ${truncated.map(item => `${item.displayPath} from ${item.originalBytes} to ${item.includedBytes} bytes`).join(', ')}`) + } + return `Workspace instruction budget ${maxBytes} bytes: ${parts.join('; ')}` +} + +function buildInstructionText( + files: LoadedInstructionFile[], + maxBytes: number, + omitted: InstructionFile[], + truncated: TruncatedInstruction[], + style: RenderStyle, +): string { + const marker = markerText(maxBytes, omitted, truncated) + const body = [marker, style.intro, ...files.map(file => style.section(file))].filter(block => block.length > 0) + return [SYSTEM_REMINDER_OPEN, body.join('\n\n'), SYSTEM_REMINDER_CLOSE].join('\n') +} + +function withTruncatedContent(file: LoadedInstructionFile, includedBytes: number): LoadedInstructionFile { + return { ...file, content: truncateUtf8(file.content, includedBytes) } +} + +function truncateToFit( + file: LoadedInstructionFile, + includedFiles: LoadedInstructionFile[], + maxBytes: number, + omitted: InstructionFile[], + style: RenderStyle, +): LoadedInstructionFile { + const originalBytes = byteLength(file.content) + let low = 0 + let high = originalBytes + let best = withTruncatedContent(file, 0) + while (low <= high) { + const mid = Math.floor((low + high) / 2) + const candidate = withTruncatedContent(file, mid) + const truncated = [{ displayPath: file.displayPath, originalBytes, includedBytes: byteLength(candidate.content) }] + const text = buildInstructionText([...includedFiles, candidate], maxBytes, omitted, truncated, style) + if (byteLength(text) <= maxBytes) { + best = candidate + low = mid + 1 + } else { + high = mid - 1 + } + } + return best +} + +function renderInstructionContext( + files: LoadedInstructionFile[], + maxBytes: number, + style: RenderStyle, +): RenderedWorkspaceContext { + if (maxBytes <= 0 || !Number.isFinite(maxBytes)) return { text: '', omitted: files, truncated: [] } + + const fullText = buildInstructionText(files, maxBytes, [], [], style) + if (byteLength(fullText) <= maxBytes) return { text: fullText, omitted: [], truncated: [] } + + for (let start = 1; start < files.length; start += 1) { + const included = files.slice(start) + const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath })) + const suffixText = buildInstructionText(included, maxBytes, omitted, [], style) + if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [] } + } + + const mostSpecific = files.at(-1) + /* v8 ignore next -- callers only reach this after a non-empty fullText was built. */ + if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [] } + const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath })) + + for (const candidateStyle of [style, { ...style, intro: COMPACT_WORKSPACE_CONTEXT_INTRO }]) { + const truncatedFile = truncateToFit(mostSpecific, [], maxBytes, omitted, candidateStyle) + const truncated = [{ + displayPath: mostSpecific.displayPath, + originalBytes: byteLength(mostSpecific.content), + includedBytes: byteLength(truncatedFile.content), + }] + const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle) + if (byteLength(text) <= maxBytes) return { text, omitted, truncated } + } + + const truncated = [{ + displayPath: mostSpecific.displayPath, + originalBytes: byteLength(mostSpecific.content), + includedBytes: 0, + }] + const compactNotice = markerText(maxBytes, omitted, truncated) + const compactWithHeading = [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n') + if (byteLength(compactWithHeading) <= maxBytes) return { text: compactWithHeading, omitted, truncated } + const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes) + return { text, omitted, truncated } +} + +/** + * Render the baseline instruction chain with deterministic precedence budgeting. + * @param files - loaded files ordered from broadest to most specific. + * @param options - required rendering byte budget. + * @returns bounded baseline prompt text and budget diagnostics. + */ +export function renderWorkspaceContext( + files: LoadedInstructionFile[], + options: { maxBytes: number }, +): RenderedWorkspaceContext { + return renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE) +} diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts new file mode 100644 index 0000000000..ed73c4fa57 --- /dev/null +++ b/packages/context/workspace-context/src/state.ts @@ -0,0 +1,507 @@ +/** + * Session-visible workspace instruction state and dynamic reconciliation. + * + * @module @deepseek-ai/dsh-workspace-context/state + */ + +import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' +import type { Message } from '@deepseek-ai/dsh-llm' +import type { JsonValue, Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs' +import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import type { ResolvedConfig } from './config.ts' +import { instructionContentSha1 } from './digest.ts' +import { + ancestorChain, + descendantDirsBetween, + findProjectRoot, + probeScopeInstruction, + readScopeInstruction, + relativeDisplay, + type LoadedInstructionFile, +} from './files.ts' +import { + renderInstructionChanges, + scopeForDisplayPath, + type ChangeRenderItem, + type WorkspaceInstructionChange, +} from './render.ts' + +export const name = 'workspace-context' + +const PLUGIN_SOURCE = { kind: 'plugin', plugin: name } as const +const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit']) + +/** Dynamic state waiting for the loop to append its returned context event. */ +export interface PendingInstructionChange { + change: WorkspaceInstructionChange + afterSeq: number + step?: { turn: number; step: number } +} + +/** Per-scope metadata cache; instruction prose is deliberately not retained. */ +export interface InstructionVersionState { + path: string + version: FsVersion + digest: string +} + +/** Session-isolated fast-path state keyed by logical instruction scope. */ +export type InstructionVersionCache = WeakMap> + +/** A cache transition coupled to the model-visible change that authorizes it. */ +export interface InstructionVersionUpdate { + change: WorkspaceInstructionChange + state?: InstructionVersionState +} + +/** Rendered reconciliation plus cache transitions awaiting final policy. */ +export interface ReconciledInstructionContext { + context: WorkspaceHookContext + versionUpdates: InstructionVersionUpdate[] +} + +/** Plugin-owned raw context with required replay metadata. */ +export interface WorkspaceHookContext extends HookContext { + envelope: 'raw' + meta: JsonValue +} + +function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceHookContext { + const serializedChanges: JsonValue[] = changes.map(change => ({ + action: change.action, + scope: change.scope, + path: change.path, + ...change.previousPath !== undefined ? { previousPath: change.previousPath } : {}, + ...change.digest !== undefined ? { digest: change.digest } : {}, + })) + const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges } + return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, envelope: 'raw', meta } +} + +/** + * Build the request-prefix message for a rendered baseline. + * @param text - complete plugin-owned system-reminder text. + * @returns a user-role prefix message. + */ +export function workspaceContextMessage(text: string): Message { + return { role: 'user', content: [{ type: 'text', text }] } +} + +function filePathFromExecution(exec: ToolExecution): string | undefined { + if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return undefined + if (typeof exec.arguments !== 'object' || exec.arguments === null) return undefined + if (!('file_path' in exec.arguments) || typeof exec.arguments.file_path !== 'string') return undefined + const filePath = exec.arguments.file_path.trim() + return filePath.length > 0 ? filePath : undefined +} + +function isWorkspaceContextSource(source: unknown): source is typeof PLUGIN_SOURCE { + return typeof source === 'object' && source !== null + && 'kind' in source && source.kind === 'plugin' + && 'plugin' in source && source.plugin === name +} + +function isRecord(value: JsonValue | undefined): value is { [key: string]: JsonValue } { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInstructionChange[] { + if (!isRecord(meta) || meta.kind !== 'workspace-instructions' || meta.version !== 1 || !Array.isArray(meta.changes)) return [] + const changes: WorkspaceInstructionChange[] = [] + for (const value of meta.changes) { + if (!isRecord(value)) continue + if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue + if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue + if (value.previousPath !== undefined && typeof value.previousPath !== 'string') continue + if (value.digest !== undefined && typeof value.digest !== 'string') continue + changes.push({ + action: value.action, + scope: value.scope, + path: value.path, + ...value.previousPath !== undefined ? { previousPath: value.previousPath } : {}, + ...value.digest !== undefined ? { digest: value.digest } : {}, + }) + } + return changes +} + +function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstructionChange): boolean { + return a.action === b.action + && a.scope === b.scope + && a.path === b.path + && a.previousPath === b.previousPath + && a.digest === b.digest +} + +function visibleInstructionChanges( + agent: Agent, + pending: Map, +): Map { + const visibleSeqs = new Set(agent.session.surface.nodes.map(node => node.seq)) + const visible = new Map() + for (const [seq, event] of agent.session.events.entries()) { + if (event.type !== 'context/message' || !isWorkspaceContextSource(event.data.source)) continue + const changes = workspaceInstructionChanges(event.data.meta) + for (const change of changes) { + const waiting = pending.get(change.scope) + if (waiting !== undefined && seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) { + pending.delete(change.scope) + } + if (visibleSeqs.has(seq)) visible.set(change.scope, change) + } + } + for (const { change } of pending.values()) visible.set(change.scope, change) + return visible +} + +/** + * Convert retained baseline files into comparison and metadata-cache state. + * @param files - baseline files that survived rendering. + * @returns latest baseline changes and provider versions keyed by logical scope. + */ +export function baselineInstructionState(files: LoadedInstructionFile[]): { + changes: Map + versions: Map +} { + const changes = new Map() + const versions = new Map() + for (const file of files) { + const digest = instructionContentSha1(file.content) + const change: WorkspaceInstructionChange = { + action: 'set', + scope: scopeForDisplayPath(file.displayPath), + path: file.displayPath, + digest, + } + changes.set(change.scope, change) + if (file.version !== undefined) { + versions.set(change.scope, { path: file.displayPath, version: file.version, digest }) + } + } + return { changes, versions } +} + +function versionStatesFor(session: Session, cache: InstructionVersionCache): Map { + let states = cache.get(session) + if (states === undefined) { + states = new Map() + cache.set(session, states) + } + return states +} + +/** + * Keep only cache updates whose model-visible changes survived final policy. + * @param updates - proposed updates from one or more reconciliations. + * @param committedChanges - transitions retained on the authoritative result. + * @returns updates authorized by an exact retained transition. + */ +export function retainedInstructionVersionUpdates( + updates: readonly InstructionVersionUpdate[], + committedChanges: readonly WorkspaceInstructionChange[], +): InstructionVersionUpdate[] { + return updates.filter(update => committedChanges.some(change => sameInstructionChange(update.change, change))) +} + +/** + * Apply authorized metadata-cache transitions without retaining instruction prose. + * @param session - owning session. + * @param updates - ordered set/delete transitions. + * @param cache - session-isolated metadata cache. + */ +export function applyInstructionVersionUpdates( + session: Session, + updates: readonly InstructionVersionUpdate[], + cache: InstructionVersionCache, +): void { + if (updates.length === 0) return + const states = versionStatesFor(session, cache) + for (const update of updates) { + if (update.state === undefined) states.delete(update.change.scope) + else states.set(update.change.scope, update.state) + } + if (states.size === 0) cache.delete(session) +} + +function pendingChangesFor( + session: object, + pendingBySession: WeakMap>, +): Map { + let pending = pendingBySession.get(session) + if (pending === undefined) { + pending = new Map() + pendingBySession.set(session, pending) + } + return pending +} + +function openStep(session: Session): { turn: number; step: number } | undefined { + const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end') + return boundary?.type === 'step/start' ? boundary.data : undefined +} + +function invalidateInstructionVersions( + session: Session, + scopes: readonly string[], + cache: InstructionVersionCache, +): void { + const states = cache.get(session) + if (states === undefined) return + for (const scope of scopes) states.delete(scope) + if (states.size === 0) cache.delete(session) +} + +/** + * Settle provisional tool-result state against durable session events. + * A matching context event confirms the transition. If its owning step closes + * first, the loop discarded its context buffer, so both duplicate suppression + * and the metadata fast path must be re-armed for the next successful touch. + * @param session - session whose append-only log emitted `event`. + * @param event - newly committed session event. + * @param pendingBySession - provisional transitions awaiting log confirmation. + * @param versionCache - metadata fast path coupled to those transitions. + */ +export function observeInstructionSessionEvent( + session: Session, + event: SessionEvent, + pendingBySession: WeakMap>, + versionCache: InstructionVersionCache, +): void { + const pending = pendingBySession.get(session) + if (pending === undefined) return + + switch (event.type) { + case 'context/message': { + if (!isWorkspaceContextSource(event.data.source)) return + for (const change of workspaceInstructionChanges(event.data.meta)) { + const waiting = pending.get(change.scope) + if (waiting !== undefined && event.seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) { + pending.delete(change.scope) + } + } + if (pending.size === 0) pendingBySession.delete(session) + return + } + case 'step/end': { + const discardedScopes: string[] = [] + for (const [scope, waiting] of pending) { + const step = waiting.step + if (step === undefined || step.turn !== event.data.turn || step.step !== event.data.step) continue + pending.delete(scope) + discardedScopes.push(scope) + } + if (pending.size === 0) pendingBySession.delete(session) + invalidateInstructionVersions(session, discardedScopes, versionCache) + return + } + default: + // SessionEventMap is merge-extensible; unrelated events do not settle workspace state. + return + } +} + +/** + * 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[] = [] + const step = openStep(agent.session) + 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, + ...step === undefined ? {} : { step }, + }) + 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 +} + +/** + * 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 pendingBySession - short pending window before returned context is logged. + * @param baselineBySession - frozen baseline comparison state per session. + * @param versionCache - per-session scope metadata used to skip unchanged reads. + * @param fileSystem - provider used for current file probes. + * @param options - touched path and whether baseline scopes should be checked. + * @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable. + */ +export async function reconcileInstructionContext( + agent: Agent, + resolved: ResolvedConfig, + pendingBySession: WeakMap>, + baselineBySession: WeakMap>, + versionCache: InstructionVersionCache, + fileSystem: FileSystem, + options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal }, +): Promise { + const session = agent.session + const pending = pendingChangesFor(session, pendingBySession) + const visible = visibleInstructionChanges(agent, pending) + const effective = new Map(baselineBySession.get(session) ?? []) + 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() + // TODO(frozen-project-root): retain the baseline root for the loop instance; + // recomputing it after marker edits reinterprets the existing relative scope keys. + const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal) + const scopes = new Set() + if (options.includeBaselineScopes) { + scopes.add('user-global') + for (const dir of ancestorChain(projectRoot, cwd)) scopes.add(relativeScope(projectRoot, dir)) + } + for (const scope of effective.keys()) scopes.add(scope) + if (options.touchedPath !== undefined) { + for (const dir of descendantDirsBetween(cwd, options.touchedPath)) scopes.add(relativeScope(projectRoot, dir)) + } + + const versions = versionStatesFor(session, versionCache) + const seenAbsolutePaths = new Set() + const items: ChangeRenderItem[] = [] + const versionUpdates: InstructionVersionUpdate[] = [] + for (const scope of scopes) { + const previous = effective.get(scope) + const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal) + if (probe.kind === 'unavailable') continue + if (probe.kind === 'absent') { + if (previous === undefined || previous.action === 'remove') { + versions.delete(scope) + continue + } + const change: WorkspaceInstructionChange = { action: 'remove', scope, path: previous.path } + items.push({ + change, + file: { absolutePath: `removed:${scope}`, displayPath: previous.path, content: '' }, + }) + versionUpdates.push({ change }) + continue + } + const { file: probedFile } = probe + if (seenAbsolutePaths.has(probedFile.absolutePath)) continue + seenAbsolutePaths.add(probedFile.absolutePath) + const cached = versions.get(scope) + if ( + cached !== undefined + && cached.path === probedFile.displayPath + && cached.version === probedFile.version + && previous !== undefined + && previous.action !== 'remove' + && previous.path === cached.path + && previous.digest === cached.digest + ) continue + + const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal) + if (file === undefined) continue + const currentDigest = instructionContentSha1(file.content) + const nextVersion: InstructionVersionState = { + path: file.displayPath, + version: probedFile.version, + digest: currentDigest, + } + if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) { + versions.set(scope, nextVersion) + continue + } + const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace' + const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath + ? previous.path + : undefined + const change: WorkspaceInstructionChange = { + action, + scope, + path: file.displayPath, + ...previousPath === undefined ? {} : { previousPath }, + digest: currentDigest, + } + items.push({ change, file }) + versionUpdates.push({ change, state: nextVersion }) + } + if (items.length === 0) return undefined + const rendered = renderInstructionChanges(items, resolved.maxBytes) + if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined + return { + context: workspaceContextHook(rendered.text, rendered.changes), + versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes), + } +} + +/** + * Validate a successful structured file touch and reconcile its applicable scopes. + * @param agent - optional agent attached to the tool execution. + * @param exec - completed tool execution descriptor. + * @param result - original tool result before post-execute decisions. + * @param resolved - normalized plugin configuration. + * @param pendingNestedChanges - per-session pending transition maps. + * @param baselineInstructionStates - retained baseline comparison state. + * @param versionCache - per-session scope metadata used to skip unchanged reads. + * @param fileSystem - provider used for current file probes. + * @returns rendered context plus deferred cache updates, or undefined for irrelevant/failed/unchanged calls. + */ +export async function dynamicInstructionContext( + agent: Agent | undefined, + exec: ToolExecution, + result: ToolExecutionResult, + resolved: ResolvedConfig, + pendingNestedChanges: WeakMap>, + baselineInstructionStates: WeakMap>, + versionCache: InstructionVersionCache, + fileSystem: FileSystem, +): Promise { + if (agent === undefined || result.isError) return undefined + const touchedPath = filePathFromExecution(exec) + if (touchedPath === undefined) return undefined + return reconcileInstructionContext( + agent, resolved, pendingNestedChanges, baselineInstructionStates, versionCache, fileSystem, + { + touchedPath, + includeBaselineScopes: baselineInstructionStates.has(agent.session), + ...exec.signal === undefined ? {} : { signal: exec.signal }, + }, + ) +} diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts new file mode 100644 index 0000000000..120740aec4 --- /dev/null +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -0,0 +1,124 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as WorkspaceContext from '@deepseek-ai/dsh-workspace-context' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import type { SessionEvent } from '@deepseek-ai/dsh-session' + +const PROBE = 'banana-271828' +const NESTED_PROBE = 'papaya-314159' +const UPDATED_PROBE = 'guava-161803' + +let ctx: Context | undefined +let workdir: string | undefined + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +async function harness(): Promise<{ ctx: Context; agent: Agent }> { + workdir = await mkdtemp(join(tmpdir(), 'dsh-workspace-context-e2e-')) + await mkdir(join(workdir, '.git'), { recursive: true }) + await writeFile(join(workdir, 'AGENTS.md'), `If the user asks for the workspace context handshake, reply with exactly this string and nothing else: ${PROBE}.\n`) + ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: 'Answer the user exactly and concisely.' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalFileSystem, { cwd: '/' }) + await ctx.plugin(ToolFs) + await ctx.plugin(WorkspaceContext, { maxBytes: 65536 }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) + const handle = await ctx.agents.create({ + agentId: AgentId('workspace-context-e2e'), + sessionId: SessionId('workspace-context-e2e-session'), + meta: { cwd: workdir }, + agentOptions: { model: 'deepseek-v4-flash' }, + }) + return { ctx, agent: handle.agent } +} + +function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +function finalText(events: SessionEvent[]): string { + const message = events.findLast(event => event.type === 'assistant/message') + if (message?.type !== 'assistant/message') return '' + return message.data.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') +} + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real model sees AGENTS.md baseline', () => { + it('obeys a probe instruction loaded from the workspace', async () => { + const live = await harness() + + live.agent.send([{ type: 'text', text: 'Workspace context handshake?' }]) + await waitForIdle(live.ctx, live.agent) + + expect(finalText([...live.agent.session.events])).toContain(PROBE) + }, 120_000) + + it('loads a nested AGENTS.md after the real read tool touches a descendant file', async () => { + const live = await harness() + await mkdir(join(workdir!, 'pkg/deep'), { recursive: true }) + await writeFile(join(workdir!, 'pkg/AGENTS.md'), `If the user asks for the nested instruction handshake, reply with exactly this string and nothing else: ${NESTED_PROBE}.\n`) + await writeFile(join(workdir!, 'pkg/deep/file.txt'), 'This file exists only to trigger nested workspace instructions.\n') + + live.agent.send([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }]) + await waitForIdle(live.ctx, live.agent) + + expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE) + }, 120_000) + + it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => { + const live = await harness() + await writeFile(join(workdir!, 'trigger.txt'), 'This file triggers workspace instruction reconciliation.\n') + live.agent.send([{ type: 'text', text: 'Workspace context handshake?' }]) + await waitForIdle(live.ctx, live.agent) + await writeFile(join(workdir!, 'AGENTS.md'), `The old workspace handshake no longer applies. If the user asks for the updated workspace context handshake, reply with exactly this string and nothing else: ${UPDATED_PROBE}.\n`) + + live.agent.send([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }]) + await waitForIdle(live.ctx, live.agent) + + const events = [...live.agent.session.events] + const update = events.find(event => event.type === 'context/message' + && typeof event.data.meta === 'object' + && event.data.meta !== null + && !Array.isArray(event.data.meta) + && event.data.meta.kind === 'workspace-instructions') + expect(update?.type === 'context/message' && update.data.meta).toMatchObject({ + changes: [{ action: 'replace', scope: '.', path: 'AGENTS.md' }], + }) + const updateText = update?.type === 'context/message' + ? update.data.content.filter(block => block.type === 'text').map(block => block.text).join('') + : '' + expect(updateText).toContain('Updated instructions from: AGENTS.md') + expect(finalText(events)).toContain(UPDATED_PROBE) + }, 120_000) +}) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts new file mode 100644 index 0000000000..82f262f34b --- /dev/null +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -0,0 +1,2885 @@ +import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' +import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' +import AgentRegistry, { AgentId, type Agent, type HookContext } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import type { + FsDirEntry, + FsEditOutcome, + FsEditRequest, + FsInfo, + FsPathInfo, + FsTarget, + FsWriteIntent, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +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, +} from '@deepseek-ai/dsh-workspace-context' +import { + baselineInstructionState, + commitPendingInstructionContexts, + observeInstructionSessionEvent, + rollbackPendingInstructionChanges, + type InstructionVersionCache, + type PendingInstructionChange, +} from '../src/state.ts' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +async function tempRepo(): Promise { + return mkdtemp(join(tmpdir(), 'dsh-workspace-context-')) +} + +async function write(path: string, content: string): Promise { + await mkdir(join(path, '..'), { recursive: true }) + await writeFile(path, content) +} + +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; 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, 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 + const info: FsInfo = { + version: entry.version ?? FsVersion(`v:${target.targetKey}:${entry.type}:${entry.content ?? ''}`), + type: entry.type, + } + 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 }, 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, signal) + if (info === undefined) return undefined + return { + version: info.version, + type: info.type, + ...(info.size !== undefined ? { size: info.size } : {}), + } + } + + 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, 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 { + return [] + } + + override async writeText(_target: FsTarget, _content: string, _expected?: FsWriteIntent): Promise { + return { operation: 'update', version: FsVersion('unused'), before: '', after: _content } + } + + override async editText(_target: FsTarget, _edit: FsEditRequest): Promise { + return { version: FsVersion('unused'), before: '', after: '' } + } +} + +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) +} + +async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalFileSystem, { cwd: '/' }) + await ctx.plugin(ToolFs) + return ctx.plugin(workspaceContext, config) +} + +function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { + const id = SessionId('s1') + const session = new Session(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd }) + return { + ctx: new Context(), + id: AgentId('a1'), + options: {}, + session, + status: 'idle', + send() {}, + steer() {}, + inject(content, options) { + session.append('context/message', { + content, + source: options?.source ?? { kind: 'user' }, + ...options?.envelope !== undefined ? { envelope: options.envelope } : {}, + ...options?.meta !== undefined ? { meta: options.meta } : {}, + }, { surfaceOp: 'append' }) + }, + cancel() {}, + whenIdle: () => Promise.resolve(), + } +} + +function stubToolExecution(input: Omit): ToolExecution { + return { + token: Symbol('workspace-context-test-execution') as ToolExecutionToken, + ...input, + } +} + +function blocksText(blocks: { type: string; text?: string }[] | undefined): string { + return blocks?.map(block => block.type === 'text' ? block.text ?? '' : '').join('\n') ?? '' +} + +function workspaceContextOf(result: { additionalContexts?: HookContext[] }): HookContext | undefined { + return result.additionalContexts?.find(context => + 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 ?? []) { + lastSeq = agent.session.append('context/message', { + content: context.content, + source: context.source, + ...context.envelope !== undefined ? { envelope: context.envelope } : {}, + ...context.meta !== undefined ? { meta: context.meta } : {}, + }, { surfaceOp: 'append' }).seq + } + return lastSeq +} + +const composedPrefixes = new WeakMap() + +async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise { + const empty: Message[] = [] + const prefix = await ctx.waterfall( + 'agent/session-prefix', agent, empty, AbortSignal.timeout(1000), + () => Promise.resolve(empty), + ) + composedPrefixes.set(agent, prefix) + return prefix +} + +function derivedText(agent: Agent): string { + return blocksText(composedPrefixes.get(agent)?.[0]?.content) +} + +function expectNoDerivedMessages(agent: Agent): void { + expect(agent.session.deriveMessages()).toEqual([]) + expect(composedPrefixes.get(agent) ?? []).toEqual([]) +} + +describe('workspace context instruction discovery', () => { + it('treats ENOTDIR while probing a host candidate as confirmed absence', async () => { + const root = await tempRepo() + const homeFile = join(root, 'not-a-directory') + try { + await writeFile(homeFile, 'file') + + const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: homeFile }) + + expect(files).toEqual([]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('loads user-global first, then root-to-cwd workspace instructions using the default candidate order', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + const cwd = join(root, 'packages/app') + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(home, 'AGENTS.md'), 'global rules') + await write(join(root, 'AGENTS.md'), 'root agents') + await write(join(root, 'CLAUDE.md'), 'root claude ignored') + await write(join(root, 'packages/CLAUDE.md'), 'package claude') + await write(join(cwd, 'AGENTS.md'), 'app agents') + + const files = await discoverBaselineInstructionFiles({ cwd, dshHome: home }) + + expect(files.map(file => file.displayPath)).toEqual([ + '$DSH_HOME/AGENTS.md', + 'AGENTS.md', + 'packages/CLAUDE.md', + 'packages/app/AGENTS.md', + ]) + expect(files.map(file => file.absolutePath)).not.toContain(join(root, 'CLAUDE.md')) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('treats a .git file as a project root marker and does not search above it', async () => { + const outer = await tempRepo() + const home = await tempRepo() + try { + const root = join(outer, 'worktree') + const cwd = join(root, 'src') + await write(join(outer, 'AGENTS.md'), 'outer must not load') + await write(join(root, '.git'), 'gitdir: ../.git/worktrees/worktree') + await write(join(root, 'AGENTS.md'), 'root') + await mkdir(cwd, { recursive: true }) + + const files = await discoverBaselineInstructionFiles({ cwd, dshHome: home }) + + expect(files.map(file => file.displayPath)).toEqual(['AGENTS.md']) + } finally { + await rm(outer, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('re-reads content after a same-version, same-size rewrite', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + const cwd = join(root, 'pkg') + await mkdir(join(root, '.git'), { recursive: true }) + await mkdir(cwd, { recursive: true }) + + 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 }) + expect(first?.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 }) + expect(second?.text).toContain('other') + expect(second?.text).not.toContain('first') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('skips a file that becomes unreadable after discovery without failing the request', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + const cwd = join(root, 'pkg') + await mkdir(join(root, '.git'), { recursive: true }) + await mkdir(cwd, { recursive: true }) + const leaf = join(cwd, 'AGENTS.md') + await write(leaf, 'secret-ish rule') + await chmod(leaf, 0) + + const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 }) + + expect(loaded).toBeUndefined() + await chmod(leaf, 0o600) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('rejects symlinked instruction files instead of following repository-controlled links', async () => { + const root = await tempRepo() + const home = await tempRepo() + const outside = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(outside, 'secret.txt'), 'outside secret') + await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md')) + + const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: home }) + const loaded = await loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 }) + + expect(files).toEqual([]) + expect(loaded).toBeUndefined() + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + await rm(outside, { recursive: true, force: true }) + } + }) + + it('rejects symlinked instruction files through ctx.fs instead of following repository-controlled links', async () => { + const root = await tempRepo() + const home = await tempRepo() + const outside = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(outside, 'secret.txt'), 'outside secret') + await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md')) + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expectNoDerivedMessages(agent) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + await rm(outside, { recursive: true, force: true }) + } + }) + + it('disables baseline loading when the byte budget is zero', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + 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 }) + } + }) + + it('honors configured instruction candidates that exclude CLAUDE.md', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'CLAUDE.md'), 'claude only') + + const files = await discoverBaselineInstructionFiles({ + cwd: root, + dshHome: home, + instructionFileCandidates: ['AGENTS.md'], + }) + + expect(files).toEqual([]) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('uses the configured instruction candidate order without hard-coding AGENTS.md priority', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'native rule') + await write(join(root, 'CLAUDE.local.md'), 'local claude rule') + await write(join(root, 'CLAUDE.md'), 'claude rule') + + const files = await discoverBaselineInstructionFiles({ + cwd: root, + dshHome: home, + instructionFileCandidates: ['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'], + }) + + expect(files.map(file => file.displayPath)).toEqual(['CLAUDE.local.md']) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('ignores configured instruction candidates that are not same-directory file names', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'native rule') + await write(join(root, '.claude/CLAUDE.md'), 'nested claude rule') + + const files = await discoverBaselineInstructionFiles({ + cwd: root, + dshHome: home, + instructionFileCandidates: ['', '.', '..', '.claude/CLAUDE.md', 'nested\\CLAUDE.md', 'AGENTS.md'], + }) + + expect(files.map(file => file.displayPath)).toEqual(['AGENTS.md']) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('defaults dshHome and uses cwd itself as root when no project marker exists', async () => { + const root = await tempRepo() + try { + const cwd = join(root, 'child') + await mkdir(cwd, { recursive: true }) + await write(join(root, 'AGENTS.md'), 'parent without marker') + await write(join(cwd, 'AGENTS.md'), 'cwd without marker') + + const files = await discoverBaselineInstructionFiles({ cwd }) + + expect(files.map(file => file.displayPath)).toEqual(['AGENTS.md']) + expect(files.map(file => file.absolutePath)).toEqual([join(cwd, 'AGENTS.md')]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('honors DSH_HOME when dshHome is not configured explicitly', async () => { + const root = await tempRepo() + const envHome = await tempRepo() + try { + await write(join(envHome, 'AGENTS.md'), 'env global rule') + vi.stubEnv('DSH_HOME', envHome) + + const files = await discoverBaselineInstructionFiles({ cwd: root }) + + expect(files).toEqual([{ absolutePath: join(envHome, 'AGENTS.md'), displayPath: '$DSH_HOME/AGENTS.md' }]) + } finally { + vi.unstubAllEnvs() + await rm(root, { recursive: true, force: true }) + await rm(envHome, { recursive: true, force: true }) + } + }) + + it('labels the default DSH home as ~/.dsh when HOME points at the configured default', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await write(join(home, '.dsh/AGENTS.md'), 'global default rule') + + vi.resetModules() + vi.doMock('node:os', () => ({ homedir: () => home })) + const isolated = await import('@deepseek-ai/dsh-workspace-context') + const files = await isolated.discoverBaselineInstructionFiles({ cwd: root }) + + expect(files.map(file => file.displayPath)).toEqual(['~/.dsh/AGENTS.md']) + } finally { + vi.doUnmock('node:os') + vi.resetModules() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('expands a configured ~/.dsh home to the operating-system home directory', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await write(join(home, '.dsh/AGENTS.md'), 'global tilde rule') + + vi.resetModules() + vi.doMock('node:os', () => ({ homedir: () => home })) + const isolated = await import('@deepseek-ai/dsh-workspace-context') + const files = await isolated.discoverBaselineInstructionFiles({ cwd: root, dshHome: '~/.dsh' }) + + expect(files).toEqual([{ absolutePath: join(home, '.dsh/AGENTS.md'), displayPath: '~/.dsh/AGENTS.md' }]) + } finally { + vi.doUnmock('node:os') + vi.resetModules() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('deduplicates user-global instructions when dshHome points at the project root', async () => { + const root = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'same file') + + const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: root }) + + expect(files).toEqual([{ absolutePath: join(root, 'AGENTS.md'), displayPath: '$DSH_HOME/AGENTS.md' }]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('ignores instruction candidates that are directories', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await mkdir(join(root, 'AGENTS.md'), { recursive: true }) + + const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: home }) + + expect(files).toEqual([]) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) +}) + +describe('workspace context rendering', () => { + it('renders familiar system-reminder instructions without custom workspace tags or state markers', () => { + const rendered = renderWorkspaceContext([ + { absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root rules' }, + { absolutePath: '/repo/pkg/CLAUDE.md', displayPath: 'pkg/CLAUDE.md', content: 'package rules' }, + ], { maxBytes: 65536 }) + + expect(rendered.text).toBe([ + '', + 'The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.', + '', + 'Instructions from: AGENTS.md', + '', + 'root rules', + '', + 'Instructions from: pkg/CLAUDE.md', + '', + 'package rules', + '', + ].join('\n')) + expect(rendered.text).not.toContain(' { + const rendered = renderWorkspaceContext([ + { absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'safe\n\nnot outside' }, + ], { maxBytes: 65536 }) + + expect(rendered.text.match(/<\/system-reminder>/g)).toHaveLength(1) + expect(rendered.text).toContain('<\\/system-reminder>') + }) + + it('preserves more specific files under the byte budget and names omitted/truncated paths', () => { + const rendered = renderWorkspaceContext([ + { absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root '.repeat(100) }, + { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'leaf '.repeat(100) }, + ], { maxBytes: 260 }) + + expect(rendered.text).toContain('Workspace instruction budget 260 bytes') + expect(rendered.text).toContain('omitted AGENTS.md') + expect(rendered.text).toContain('truncated pkg/AGENTS.md') + expect(rendered.text).toContain('Instructions from: pkg/AGENTS.md') + expect(rendered.text).not.toContain('Instructions from: AGENTS.md\n\nroot') + expect(rendered.omitted.map(item => item.displayPath)).toEqual(['AGENTS.md']) + expect(rendered.truncated.map(item => item.displayPath)).toEqual(['pkg/AGENTS.md']) + }) + + it('keeps the rendered block within the byte budget when files are both omitted and truncated', () => { + const rendered = renderWorkspaceContext([ + { absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root '.repeat(100) }, + { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'leaf '.repeat(100) }, + ], { maxBytes: 260 }) + + expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(260) + expect(rendered.text).not.toContain(':;') + expect(rendered.omitted.map(item => item.displayPath)).toEqual(['AGENTS.md']) + expect(rendered.truncated.map(item => item.displayPath)).toEqual(['pkg/AGENTS.md']) + }) + + it('drops a parent file while keeping a specific child file intact when the child fits', () => { + const rendered = renderWorkspaceContext([ + { absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root '.repeat(200) }, + { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'leaf rule' }, + ], { maxBytes: 700 }) + + expect(rendered.text).toContain('omitted AGENTS.md') + expect(rendered.text).toContain('Instructions from: pkg/AGENTS.md\n\nleaf rule') + expect(rendered.text).not.toContain('root root') + expect(rendered.omitted.map(item => item.displayPath)).toEqual(['AGENTS.md']) + expect(rendered.truncated).toEqual([]) + }) + + it('keeps the longest most-specific suffix that fits under the byte budget', () => { + const rendered = renderWorkspaceContext([ + { absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root '.repeat(200) }, + { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'package rule' }, + { absolutePath: '/repo/pkg/app/AGENTS.md', displayPath: 'pkg/app/AGENTS.md', content: 'app rule' }, + ], { maxBytes: 760 }) + + expect(rendered.text).toContain('omitted AGENTS.md') + expect(rendered.text).toContain('Instructions from: pkg/AGENTS.md\n\npackage rule') + expect(rendered.text).toContain('Instructions from: pkg/app/AGENTS.md\n\napp rule') + expect(rendered.text).not.toContain('root root') + expect(rendered.omitted.map(item => item.displayPath)).toEqual(['AGENTS.md']) + expect(rendered.truncated).toEqual([]) + }) + + it('truncates a single oversized file to the largest content slice that fits', () => { + const rendered = renderWorkspaceContext([ + { absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'x'.repeat(1000) }, + ], { maxBytes: 700 }) + + expect(rendered.text).toContain('truncated AGENTS.md') + expect(rendered.text).toContain('Instructions from: AGENTS.md') + expect(rendered.truncated).toHaveLength(1) + expect(rendered.truncated[0]?.originalBytes).toBe(1000) + expect(rendered.truncated[0]!.includedBytes).toBeGreaterThan(0) + expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(700) + }) + + it('omits all text when the render budget is disabled', () => { + const rendered = renderWorkspaceContext([ + { absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root rules' }, + ], { maxBytes: 0 }) + + expect(rendered).toEqual({ + text: '', + omitted: [{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root rules' }], + truncated: [], + }) + }) + + it('falls back to a compact truncation notice when even the empty heading cannot fit', () => { + const rendered = renderWorkspaceContext([ + { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) }, + ], { maxBytes: 100 }) + + expect(rendered.text).toBe('Workspace instruction budget 100 bytes: truncated pkg/AGENTS.md from 1000 to 0 bytes') + expect(rendered.truncated).toEqual([{ displayPath: 'pkg/AGENTS.md', originalBytes: 1000, includedBytes: 0 }]) + expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(100) + }) + + it('keeps the empty instruction heading when it fits beside the compact notice', () => { + const rendered = renderWorkspaceContext([ + { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) }, + ], { maxBytes: 120 }) + + expect(rendered.text).toBe([ + 'Workspace instruction budget 120 bytes: truncated pkg/AGENTS.md from 1000 to 0 bytes', + '', + 'Instructions from: pkg/AGENTS.md', + '', + '', + ].join('\n')) + expect(Buffer.byteLength(rendered.text, 'utf8')).toBe(120) + }) + + it('truncates the compact notice itself when the render budget is smaller than the notice', () => { + const rendered = renderWorkspaceContext([ + { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) }, + ], { maxBytes: 20 }) + + expect(rendered.text).toBe('Workspace instructio') + expect(rendered.truncated).toEqual([{ displayPath: 'pkg/AGENTS.md', originalBytes: 1000, includedBytes: 0 }]) + expect(Buffer.byteLength(rendered.text, 'utf8')).toBe(20) + }) + + it('keeps compact truncation notices within budget when a multibyte display path is cut', () => { + const rendered = renderWorkspaceContext([ + { absolutePath: '/repo/路径/AGENTS.md', displayPath: '路径/AGENTS.md', content: 'x'.repeat(1000) }, + ], { maxBytes: 51 }) + + expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(51) + }) +}) + +describe('workspace context request injection', () => { + it('requires an explicit maxBytes configuration', async () => { + const ctx = new Context() + + await expect(ctx.plugin(workspaceContext, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/) + }) + + it('mounts without requiring a filesystem provider', async () => { + const ctx = new Context() + try { + const outcome = await Promise.race([ + ctx.plugin(workspaceContext, { maxBytes: 65536 }).then(() => { + return 'settled' as const + }), + new Promise<'pending'>((resolve) => { + setTimeout(() => { + resolve('pending') + }, 50) + }), + ]) + + expect(outcome).toBe('settled') + } finally { + await ctx.fiber.dispose() + } + }) + + it('does not declare fs as a static inject dependency', () => { + expect('inject' in workspaceContext).toBe(false) + }) + + it('does not inject baseline context when no filesystem provider is present', async () => { + const ctx = new Context() + try { + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + const agent = stubAgent('/virtual/repo') + + await composeBaselinePrefix(ctx, agent) + + expectNoDerivedMessages(agent) + } finally { + await ctx.fiber.dispose() + } + }) + + it('leaves post-execute decisions unchanged when no filesystem provider is present', async () => { + const ctx = new Context() + try { + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + + const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({ + callId: CallId('no-fs-post-execute'), + name: 'read', + arguments: { file_path: 'pkg/file.txt' }, + agent: stubAgent('/virtual/repo'), + }), { + isError: false, + content: [{ type: 'text', text: 'file content' }], + }, async () => ({ + kind: 'accept', + content: [{ type: 'text', text: 'downstream content' }], + })) + + expect(decision).toEqual({ kind: 'accept', content: [{ type: 'text', text: 'downstream content' }] }) + } finally { + await ctx.fiber.dispose() + } + }) + + it('does not load workspace instructions when a downstream listener blocks the tool call', async () => { + const root = await tempRepo() + const home = await tempRepo() + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + 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 package rule' }) + fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) + await ctx.plugin(ToolFs) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const exec = stubToolExecution({ + callId: CallId('read-blocked-post-execute'), + name: 'read', + arguments: { file_path: 'pkg/file.txt' }, + agent, + }) + const result = { + isError: false, + content: [{ type: 'text' as const, text: 'hello' }], + } + + // A later PostToolUse-style policy blocks this otherwise-successful read. + const blocked = await ctx.waterfall('tools/post-execute', exec, result, async () => ({ + kind: 'block' as const, + feedback: [{ type: 'text' as const, text: 'blocked by policy' }], + })) + + expect(blocked).toEqual({ + kind: 'block', + feedback: [{ type: 'text', text: 'blocked by policy' }], + }) + expect(blocked.additionalContexts).toBeUndefined() + + // The same read, when the downstream accepts, DOES surface the nested + // instructions — proving the block branch above is what suppressed them, + // and that the block did not consume the pending nested change. + const accepted = await ctx.waterfall('tools/post-execute', exec, result, async () => ({ + kind: 'accept' as const, + })) + expect(accepted.kind).toBe('accept') + expect(workspaceContextOf(accepted)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) + 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('contributes baseline instructions through the frozen session prefix instead of durable history', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expect(agent.session.deriveMessages()).toEqual([]) + expect(composedPrefixes.get(agent)).toHaveLength(1) + expect(derivedText(agent)).toContain('') + expect(derivedText(agent)).toContain('Instructions from: AGENTS.md') + expect(derivedText(agent)).toContain('repo rule') + expect(derivedText(agent)).not.toContain(' { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const first = await composeBaselinePrefix(ctx, agent) + const second = await composeBaselinePrefix(ctx, agent) + + expect(second).toEqual(first) + expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0) + expect(derivedText(agent)).toContain('repo rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('tracks only baseline files that were actually included under the byte budget', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + const cwd = join(root, 'pkg') + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'root '.repeat(200)) + await write(join(cwd, 'AGENTS.md'), 'package rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 }) + const agent = stubAgent(cwd) + + await composeBaselinePrefix(ctx, agent) + + expect(derivedText(agent)).toContain('omitted AGENTS.md') + expect(derivedText(agent)).toContain('Instructions from: pkg/AGENTS.md\n\npackage rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('places workspace instructions before later session-prefix contributors such as a skills catalog', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => { + const rest = await next() + return [{ role: 'user', content: [{ type: 'text', text: 'Available skills' }] }, ...rest] + }) + + const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) + + expect(prefix).toHaveLength(2) + expect(blocksText(prefix[0]?.content)).toContain('Instructions from: AGENTS.md') + expect(blocksText(prefix[1]?.content)).toBe('Available skills') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('appends a replacement when a frozen baseline file changes before a later fs tool call', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'old root rule') + await write(join(root, 'file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + await write(join(root, 'AGENTS.md'), 'new root rule with more detail') + const result = await ctx.tools.execute({ + callId: CallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent, + }) + + expect(workspaceContextOf(result)?.meta).toMatchObject({ + changes: [{ action: 'replace', scope: '.', path: 'AGENTS.md' }], + }) + expect(blocksText(workspaceContextOf(result)?.content)).toContain('Updated instructions from: AGENTS.md') + expect(blocksText(workspaceContextOf(result)?.content)).toContain('new root rule with more detail') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('appends a removal when a frozen baseline file is deleted before a later fs tool call', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'root rule') + await write(join(root, 'file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + await rm(join(root, 'AGENTS.md')) + const result = await ctx.tools.execute({ + callId: CallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent, + }) + + expect(workspaceContextOf(result)?.meta).toMatchObject({ + changes: [{ action: 'remove', scope: '.', path: 'AGENTS.md' }], + }) + expect(blocksText(workspaceContextOf(result)?.content)).toContain('Instructions removed: AGENTS.md') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('deduplicates one AGENTS.md that is both user-global and the project-root candidate', async () => { + const root = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'shared root and global rule') + await write(join(root, 'file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: root, maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + const result = await ctx.tools.execute({ + callId: CallId('read-with-shared-global-root'), name: 'read', arguments: { file_path: 'file.txt' }, agent, + }) + + expect(derivedText(agent).match(/shared root and global rule/g)).toHaveLength(1) + expect(result.additionalContexts).toBeUndefined() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('does not expose state markers when a tiny budget reduces the baseline contribution', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 10 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0) + expect(derivedText(agent)).not.toContain('workspace-context:') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('loads instruction file content through ctx.fs instead of direct node reads', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'node fs rule') + const ctx = new Context() + 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: 'ctx.fs rule' }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expect(derivedText(agent)).toContain('ctx.fs rule') + expect(derivedText(agent)).not.toContain('node fs rule') + expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')]) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('loads provider-visible instruction files that do not exist on the host filesystem', 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: 'provider-only rule' }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expect(derivedText(agent)).toContain('provider-only rule') + expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')]) + } finally { + await ctx.fiber.dispose() + await rm(dirname(root), { recursive: true, force: true }) + await rm(dirname(home), { recursive: true, force: true }) + } + }) + + 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() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(home, 'AGENTS.md'), 'node global rule') + await write(join(root, 'CLAUDE.md'), 'node claude rule') + const ctx = new Context() + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(home, 'AGENTS.md'), { type: 'file', content: 'ctx global rule' }) + fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'ctx claude rule' }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expect(derivedText(agent)).toContain('ctx global rule') + expect(derivedText(agent)).toContain('ctx claude rule') + expect(derivedText(agent)).not.toContain('node global rule') + expect(derivedText(agent)).not.toContain('node claude rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('skips provider-visible instruction candidates when ctx.fs reports a non-file target', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'node fs rule') + const ctx = new Context() + 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: 'directory' }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expectNoDerivedMessages(agent) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('skips provider-visible instruction candidates when ctx.fs stat disagrees after no-follow preflight', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'node fs rule') + const ctx = new Context() + 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: 'directory' }) + fs.lstatTypes.set(join(root, 'AGENTS.md'), 'file') + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expectNoDerivedMessages(agent) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('loads instruction files when ctx.fs omits the metadata size', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'node fs rule') + const ctx = new Context() + 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' }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expect(derivedText(agent)).toContain('Instructions from: AGENTS.md') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('skips provider-visible instruction candidates when ctx.fs cannot stat them', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'node fs rule') + const ctx = new Context() + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.throwOnStat.add(join(root, 'AGENTS.md')) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expectNoDerivedMessages(agent) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('does not fall through to a lower-priority candidate when the winning provider file becomes unavailable', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + const ctx = new Context() + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.lstatTypes.set(join(root, 'AGENTS.md'), 'file') + fs.throwOnStat.add(join(root, 'AGENTS.md')) + fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'must not bypass AGENTS failure' }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expectNoDerivedMessages(agent) + expect(fs.readTargets).not.toContain(join(root, 'CLAUDE.md')) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('treats ctx.fs marker lookup failures as absent root markers', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.throwOnStat.add(join(root, '.git')) + fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expect(derivedText(agent)).toContain('repo rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('keeps different session cwd instruction files isolated in one context', async () => { + const repoA = await tempRepo() + const repoB = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(repoA, '.git'), { recursive: true }) + await mkdir(join(repoB, '.git'), { recursive: true }) + await write(join(repoA, 'AGENTS.md'), 'repo A only') + await write(join(repoB, 'AGENTS.md'), 'repo B only') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agentA = stubAgent(repoA) + const agentB = stubAgent(repoB) + + await composeBaselinePrefix(ctx, agentA) + await composeBaselinePrefix(ctx, agentB) + + expect(derivedText(agentA)).toContain('repo A only') + expect(derivedText(agentA)).not.toContain('repo B only') + expect(derivedText(agentB)).toContain('repo B only') + expect(derivedText(agentB)).not.toContain('repo A only') + } finally { + await rm(repoA, { recursive: true, force: true }) + await rm(repoB, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('uses schema defaults on the plugin path so ancestor discovery still finds .git roots', async () => { + const root = await tempRepo() + try { + const cwd = join(root, 'child') + await mkdir(join(root, '.git'), { recursive: true }) + await mkdir(cwd, { recursive: true }) + await write(join(root, 'AGENTS.md'), 'root schema default rule') + await write(join(cwd, 'AGENTS.md'), 'child schema default rule') + const ctx = new Context() + await ctx.plugin(LocalFileSystem, { cwd: '/' }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + const agent = stubAgent(cwd) + + await composeBaselinePrefix(ctx, agent) + + expect(derivedText(agent)).toContain('Instructions from: AGENTS.md\n\nroot schema default rule') + expect(derivedText(agent)).toContain('Instructions from: child/AGENTS.md\n\nchild schema default rule') + await ctx.fiber.dispose() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('cleans up its agent/session-prefix listener when the plugin fiber is disposed', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + const fiber = await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await fiber.dispose() + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expectNoDerivedMessages(agent) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('does not inject anything when maxBytes is zero', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 0 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expectNoDerivedMessages(agent) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('does not inject an empty workspace-context message when maxBytes is negative', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: -1 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expectNoDerivedMessages(agent) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('leaves the request unchanged when no instruction files are present', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + await composeBaselinePrefix(ctx, agent) + + expectNoDerivedMessages(agent) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('labels a custom dshHome as DSH_HOME instead of pretending it is ~/.dsh', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await write(join(home, 'AGENTS.md'), 'global custom rule') + const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: home }) + + expect(files.map(file => file.displayPath)).toEqual(['$DSH_HOME/AGENTS.md']) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('does not repeat a candidate metadata probe during one discovery and read pass', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'repo rule') + + const observedStats = new Map() + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + lstat: async (path: string) => { + observedStats.set(path, (observedStats.get(path) ?? 0) + 1) + return actual.lstat(path) + }, + } + }) + const isolated = await import('@deepseek-ai/dsh-workspace-context') + await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 }) + observedStats.clear() + await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 }) + + expect(observedStats.get(join(root, 'AGENTS.md'))).toBe(1) + } finally { + vi.doUnmock('node:fs/promises') + vi.resetModules() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('does not bypass an unavailable host AGENTS.md with a lower-priority candidate', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'CLAUDE.md'), 'must not bypass unavailable AGENTS') + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + lstat: async (path: string) => { + if (path === join(root, 'AGENTS.md')) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + } + return actual.lstat(path) + }, + } + }) + const isolated = await import('@deepseek-ai/dsh-workspace-context') + + const rendered = await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 }) + + expect(rendered).toBeUndefined() + } finally { + vi.doUnmock('node:fs/promises') + vi.resetModules() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) +}) + +describe('dynamic nested workspace context injection', () => { + it('re-arms a buffered instruction change when a later tool aborts the step before context append', 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 rule survives an aborted tool batch') + await write(join(root, 'pkg/deep/file.txt'), 'hello') + const adapter = new MockAdapter([ + [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('read-before-abort'), name: 'read', arguments: '{"file_path":"pkg/deep/file.txt"}' } }, + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('abort-after-read'), name: 'abort_step', arguments: '{}' } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] satisfies StreamChunk[], + toolCallResponse('read-after-abort', 'read', { file_path: 'pkg/deep/file.txt' }), + textResponse('done'), + ]) + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalFileSystem, { cwd: '/' }) + await ctx.plugin(ToolFs) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(AgentId('workspace-context-abort'), { model: 'mock' }, { cwd: root }) + ctx.tools.register(defineTool({ + name: 'abort_step', + description: 'Abort the current test step.', + parameters: {}, + async execute() { + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('test abort') + return [{ type: 'text', text: 'aborted' }] + }, + })) + + agent.send([{ type: 'text', text: 'read and abort' }]) + await agent.whenIdle() + expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(0) + + agent.send([{ type: 'text', text: 'retry the read' }]) + await agent.whenIdle() + + const contexts = agent.session.events.filter(event => event.type === 'context/message') + expect(contexts).toHaveLength(1) + expect(adapter.requests).toHaveLength(3) + expect(adapter.requests[2]?.messages.map(blocks => blocksText(blocks.content)).join('\n')) + .toContain('nested rule survives an aborted tool batch') + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('builds persisted digest state without inventing a provider version', () => { + const state = baselineInstructionState([{ + absolutePath: '/repo/AGENTS.md', + displayPath: 'AGENTS.md', + content: 'root rule', + }]) + + const change = state.changes.get('.') + expect(change).toMatchObject({ + action: 'set', + path: 'AGENTS.md', + }) + expect(change?.digest).toMatch(/^[a-f0-9]{40}$/) + expect(state.versions).toEqual(new Map()) + }) + + 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, { + 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() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'baseline root rule') + await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') + await write(join(root, 'pkg/deep/file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const result = await ctx.tools.execute({ + callId: CallId('read-nested'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent, + }) + + expect(result.isError).toBe(false) + expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) + expect(workspaceContextOf(result)?.envelope).toBe('raw') + expect(workspaceContextOf(result)?.meta).toMatchObject({ + kind: 'workspace-instructions', + version: 1, + changes: [{ + action: 'set', + scope: 'pkg', + path: 'pkg/AGENTS.md', + }], + }) + const meta = workspaceContextOf(result)?.meta + const firstChange = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes) + ? meta.changes[0] + : undefined + const changeDigest = typeof firstChange === 'object' && firstChange !== null && !Array.isArray(firstChange) + ? firstChange.digest + : undefined + expect(changeDigest).toMatch(/^[a-f0-9]{40}$/) + const text = blocksText(workspaceContextOf(result)?.content) + expect(text).toBe([ + '', + 'Additional instructions from: pkg/AGENTS.md', + '', + 'These instructions apply to work under `pkg`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.', + '', + 'nested package rule', + '', + ].join('\n')) + expect(text).not.toContain(' { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'native package rule') + await write(join(root, 'pkg/CLAUDE.local.md'), 'local package rule') + await write(join(root, 'pkg/deep/file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { + dshHome: home, + maxBytes: 65536, + instructionFileCandidates: ['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'], + }) + + const result = await ctx.tools.execute({ + callId: CallId('read-configured-nested-candidate'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent: stubAgent(root), + }) + + const text = blocksText(workspaceContextOf(result)?.content) + expect(text).toContain('Additional instructions from: pkg/CLAUDE.local.md') + expect(text).toContain('local package rule') + expect(text).not.toContain('native package rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('does not attach nested instructions again for the same session once a path has been loaded', async () => { + const root = await tempRepo() + const home = await tempRepo() + 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') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const first = await ctx.tools.execute({ + callId: CallId('read-nested-1'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent, + }) + const second = await ctx.tools.execute({ + callId: CallId('read-nested-2'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent, + }) + + expect(first.additionalContexts).toBeDefined() + expect(second.additionalContexts).toBeUndefined() + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('skips instruction content reads while provider version and effective state are unchanged', async () => { + const root = join(await tempRepo(), 'virtual-repo') + const home = join(await tempRepo(), 'virtual-home') + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + const instructionPath = join(root, 'pkg/AGENTS.md') + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(instructionPath, { type: 'file', content: 'nested package rule' }) + fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) + await ctx.plugin(ToolFs) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const first = await ctx.tools.execute({ + callId: CallId('read-before-version-fast-path'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + appendAdditionalContexts(agent, first) + const second = await ctx.tools.execute({ + callId: CallId('read-with-version-fast-path'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + + expect(first.additionalContexts).toBeDefined() + expect(second.additionalContexts).toBeUndefined() + expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(1) + } finally { + await ctx.fiber.dispose() + await rm(dirname(root), { recursive: true, force: true }) + await rm(dirname(home), { recursive: true, force: true }) + } + }) + + it('re-reads a changed provider version, then refreshes metadata when SHA-1 is unchanged', async () => { + const root = join(await tempRepo(), 'virtual-repo') + const home = join(await tempRepo(), 'virtual-home') + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + const instructionPath = join(root, 'pkg/AGENTS.md') + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-1') }) + fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) + await ctx.plugin(ToolFs) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const first = await ctx.tools.execute({ + callId: CallId('read-before-same-digest-version-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + appendAdditionalContexts(agent, first) + fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-2') }) + const afterVersionChange = await ctx.tools.execute({ + callId: CallId('read-after-same-digest-version-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + const afterRefresh = await ctx.tools.execute({ + callId: CallId('read-after-version-cache-refresh'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + + expect(afterVersionChange.additionalContexts).toBeUndefined() + expect(afterRefresh.additionalContexts).toBeUndefined() + expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(2) + } finally { + await ctx.fiber.dispose() + await rm(dirname(root), { recursive: true, force: true }) + await rm(dirname(home), { recursive: true, force: true }) + } + }) + + it('isolates instruction version caches between sessions that touch the same scope', async () => { + const root = join(await tempRepo(), 'virtual-repo') + const home = join(await tempRepo(), 'virtual-home') + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + const instructionPath = join(root, 'pkg/AGENTS.md') + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(instructionPath, { type: 'file', content: 'shared path, separate sessions' }) + fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) + await ctx.plugin(ToolFs) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + + const first = await ctx.tools.execute({ + callId: CallId('read-from-first-session'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: stubAgent(root), + }) + const second = await ctx.tools.execute({ + callId: CallId('read-from-second-session'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: stubAgent(root), + }) + + expect(first.additionalContexts).toBeDefined() + expect(second.additionalContexts).toBeDefined() + expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(2) + } finally { + await ctx.fiber.dispose() + await rm(dirname(root), { recursive: true, force: true }) + await rm(dirname(home), { recursive: true, force: true }) + } + }) + + it('replaces previously loaded instructions when the same file content changes', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'old package rule') + await write(join(root, 'pkg/file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const first = await ctx.tools.execute({ + callId: CallId('read-before-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + appendAdditionalContexts(agent, first) + await write(join(root, 'pkg/AGENTS.md'), 'new package rule with more detail') + const changed = await ctx.tools.execute({ + callId: CallId('read-after-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + + expect(workspaceContextOf(changed)?.meta).toMatchObject({ + kind: 'workspace-instructions', + changes: [{ action: 'replace', scope: 'pkg', path: 'pkg/AGENTS.md' }], + }) + expect(blocksText(workspaceContextOf(changed)?.content)).toBe([ + '', + 'Updated instructions from: pkg/AGENTS.md', + '', + 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.', + '', + 'new package rule with more detail', + '', + ].join('\n')) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('replaces an AGENTS candidate with the configured fallback in the same scope', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'native package rule') + await write(join(root, 'pkg/CLAUDE.md'), 'fallback package rule') + await write(join(root, 'pkg/file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const first = await ctx.tools.execute({ + callId: CallId('read-before-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + appendAdditionalContexts(agent, first) + await rm(join(root, 'pkg/AGENTS.md')) + const changed = await ctx.tools.execute({ + callId: CallId('read-after-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + appendAdditionalContexts(agent, changed) + const unchanged = await ctx.tools.execute({ + callId: CallId('read-after-logged-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + + expect(workspaceContextOf(changed)?.meta).toMatchObject({ + changes: [{ + action: 'replace', scope: 'pkg', path: 'pkg/CLAUDE.md', previousPath: 'pkg/AGENTS.md', + }], + }) + expect(blocksText(workspaceContextOf(changed)?.content)).toContain('Updated instructions from: pkg/CLAUDE.md') + expect(blocksText(workspaceContextOf(changed)?.content)).toContain('The instructions previously loaded from `pkg/AGENTS.md` no longer apply. Use the following content for `pkg` instead.') + expect(blocksText(workspaceContextOf(changed)?.content)).toContain('fallback package rule') + expect(unchanged.additionalContexts).toBeUndefined() + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('removes previously loaded instructions when no candidate remains in the scope', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'package rule') + await write(join(root, 'pkg/file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const first = await ctx.tools.execute({ + callId: CallId('read-before-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + appendAdditionalContexts(agent, first) + await rm(join(root, 'pkg/AGENTS.md')) + const removed = await ctx.tools.execute({ + callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + + expect(workspaceContextOf(removed)?.meta).toEqual({ + kind: 'workspace-instructions', + version: 1, + changes: [{ action: 'remove', scope: 'pkg', path: 'pkg/AGENTS.md' }], + }) + expect(blocksText(workspaceContextOf(removed)?.content)).toBe([ + '', + 'Instructions removed: pkg/AGENTS.md', + '', + 'The previously loaded instructions from this file no longer apply.', + '', + ].join('\n')) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('loads a candidate again after a logged removal tombstone', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'first package rule') + await write(join(root, 'pkg/file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const first = await ctx.tools.execute({ + callId: CallId('read-before-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + appendAdditionalContexts(agent, first) + await rm(join(root, 'pkg/AGENTS.md')) + const removed = await ctx.tools.execute({ + callId: CallId('read-to-create-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + appendAdditionalContexts(agent, removed) + await write(join(root, 'pkg/AGENTS.md'), 'restored package rule') + + const restored = await ctx.tools.execute({ + callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + + expect(workspaceContextOf(restored)?.meta).toMatchObject({ + changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }], + }) + expect(blocksText(workspaceContextOf(restored)?.content)).toContain('Additional instructions from: pkg/AGENTS.md') + expect(blocksText(workspaceContextOf(restored)?.content)).toContain('restored package rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('does not report removal when a previously loaded scope is temporarily unavailable', async () => { + const root = await tempRepo() + const home = await tempRepo() + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + 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: 'provider package rule' }) + fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) + await ctx.plugin(ToolFs) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const first = await ctx.tools.execute({ + callId: CallId('read-before-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + appendAdditionalContexts(agent, first) + fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) + const duringFailure = await ctx.tools.execute({ + callId: CallId('read-during-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }) + + expect(first.additionalContexts).toBeDefined() + expect(duringFailure.additionalContexts).toBeUndefined() + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('derives loaded nested instructions from resumed session history instead of duplicating them', async () => { + const root = await tempRepo() + const home = await tempRepo() + 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') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + const first = await ctx.tools.execute({ + callId: CallId('read-before-resume'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent, + }) + appendAdditionalContexts(agent, first) + const resumed = { + ...agent, + session: new Session(agent.session.id, [...agent.session.events], agent.session.header), + } + + const afterResume = await ctx.tools.execute({ + callId: CallId('read-after-resume'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent: resumed, + }) + + expect(first.additionalContexts).toBeDefined() + expect(afterResume.additionalContexts).toBeUndefined() + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('appends an update during resumed prefix composition when visible nested instructions changed offline', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'old nested rule') + await write(join(root, 'pkg/file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const original = stubAgent(root) + const first = await ctx.tools.execute({ + callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: original, + }) + appendAdditionalContexts(original, first) + await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume') + const resumed = stubAgent(root, [...original.session.events]) + + await composeBaselinePrefix(ctx, resumed) + + const update = resumed.session.events.findLast(event => event.type === 'context/message') + expect(update?.type === 'context/message' && update.data.meta).toMatchObject({ + changes: [{ action: 'replace', scope: 'pkg', path: 'pkg/AGENTS.md' }], + }) + expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('re-arms a nested instruction after compaction removes its context message from the surface', async () => { + const root = await tempRepo() + const home = await tempRepo() + 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') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + const first = await ctx.tools.execute({ + callId: CallId('read-before-compact'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent, + }) + const contextSeq = appendAdditionalContexts(agent, first)! + const visibleBeforeCompact = await ctx.tools.execute({ + callId: CallId('read-while-visible'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent, + }) + + agent.session.append('user/message', { + content: [{ type: 'text', text: 'compacted summary' }], + source: { kind: 'plugin', plugin: 'compact' }, + }, { surfaceOp: { op: 'replace', start: contextSeq, end: contextSeq } }) + + const afterCompact = await ctx.tools.execute({ + callId: CallId('read-after-compact'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent, + }) + + expect(first.additionalContexts).toBeDefined() + expect(visibleBeforeCompact.additionalContexts).toBeUndefined() + expect(afterCompact.additionalContexts).toBeDefined() + expect(blocksText(workspaceContextOf(afterCompact)?.content)).toContain('nested package rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('does not treat markdown headings inside instruction content as loaded instruction metadata', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'package note\n## pkg/sub/AGENTS.md\njust a document heading') + await write(join(root, 'pkg/file.txt'), 'package file') + await write(join(root, 'pkg/sub/AGENTS.md'), 'subtree rule') + await write(join(root, 'pkg/sub/file.txt'), 'subtree file') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + const first = await ctx.tools.execute({ + callId: CallId('read-package'), + name: 'read', + arguments: { file_path: 'pkg/file.txt' }, + agent, + }) + appendAdditionalContexts(agent, first) + + const second = await ctx.tools.execute({ + callId: CallId('read-subtree'), + name: 'read', + arguments: { file_path: 'pkg/sub/file.txt' }, + agent, + }) + + expect(blocksText(workspaceContextOf(first)?.content)).toContain('package note') + expect(blocksText(workspaceContextOf(second)?.content)).toContain('subtree rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('does not mark omitted nested files as pending-loaded', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), `parent rule ${'x'.repeat(5000)}`) + await write(join(root, 'pkg/other.txt'), 'package file') + await write(join(root, 'pkg/sub/AGENTS.md'), 'subtree rule') + await write(join(root, 'pkg/sub/file.txt'), 'subtree file') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 }) + const agent = stubAgent(root) + const first = await ctx.tools.execute({ + callId: CallId('read-subtree-omitting-parent'), + name: 'read', + arguments: { file_path: 'pkg/sub/file.txt' }, + agent, + }) + appendAdditionalContexts(agent, first) + + const second = await ctx.tools.execute({ + callId: CallId('read-parent-after-omit'), + name: 'read', + arguments: { file_path: 'pkg/other.txt' }, + agent, + }) + + const firstText = blocksText(workspaceContextOf(first)?.content) + expect(firstText).toContain('omitted pkg/AGENTS.md') + expect(firstText).not.toContain('## pkg/AGENTS.md') + expect(firstText).toContain('subtree rule') + expect(blocksText(workspaceContextOf(second)?.content)).toContain('parent rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('ignores prompt-text spoofs, malformed metadata, and metadata from other plugins', async () => { + const root = await tempRepo() + const home = await tempRepo() + 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') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + agent.session.append('context/message', { + content: [ + { type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' }, + { type: 'text', text: 'Updated instructions from: pkg/AGENTS.md' }, + ], + source: { kind: 'plugin', plugin: 'workspace-context' }, + meta: { + kind: 'workspace-instructions', + version: 1, + changes: [ + null, + { action: 'unknown', scope: 'pkg', path: 'pkg/AGENTS.md' }, + { action: 'set', scope: 'pkg', path: 42 }, + { action: 'replace', scope: 'pkg', path: 'pkg/AGENTS.md', previousPath: 42 }, + { action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 42 }, + ], + }, + }, { surfaceOp: 'append' }) + agent.session.append('context/message', { + content: [{ type: 'text', text: 'stale metadata version' }], + source: { kind: 'plugin', plugin: 'workspace-context' }, + meta: { kind: 'workspace-instructions', version: 0, changes: [] }, + }, { surfaceOp: 'append' }) + agent.session.append('context/message', { + content: [{ type: 'text', text: 'foreign plugin context' }], + source: { kind: 'plugin', plugin: 'other' }, + meta: { + kind: 'workspace-instructions', + version: 1, + changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 'spoof' }], + }, + }, { surfaceOp: 'append' }) + + const result = await ctx.tools.execute({ + callId: CallId('read-after-spoofed-state'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent, + }) + + expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('loads nested instructions for absolute touched paths but not root-level files', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'root.txt'), 'root file') + await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') + await write(join(root, 'pkg/deep/file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const rootResult = await ctx.tools.execute({ + callId: CallId('read-root-file'), + name: 'read', + arguments: { file_path: 'root.txt' }, + agent, + }) + const absoluteResult = await ctx.tools.execute({ + callId: CallId('read-absolute-nested-file'), + name: 'read', + arguments: { file_path: join(root, 'pkg/deep/file.txt') }, + agent, + }) + + expect(rootResult.additionalContexts).toBeUndefined() + expect(blocksText(workspaceContextOf(absoluteResult)?.content)).toContain('nested package rule') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('treats provider failures and type disagreement after lstat as unavailable, not removed', async () => { + const root = await tempRepo() + const home = await tempRepo() + const ctx = new Context() + try { + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.lstatTypes.set(join(root, 'pkg/AGENTS.md'), 'file') + fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + const result = { + callId: CallId('provider-probe-result'), + content: [{ type: 'text' as const, text: 'ok' }], + isError: false, + } + + const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ + callId: CallId('provider-stat-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }), result, async () => ({ kind: 'accept' as const })) + fs.throwOnStat.clear() + fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'directory' }) + const mismatchedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({ + callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, + }), result, async () => ({ kind: 'accept' as const })) + + expect(failedStat).toEqual({ kind: 'accept' }) + expect(mismatchedStat).toEqual({ kind: 'accept' }) + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('skips unreadable nested instruction files without attaching empty context', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + const nested = join(root, 'pkg/AGENTS.md') + await write(nested, 'nested package rule') + await write(join(root, 'pkg/deep/file.txt'), 'hello') + await chmod(nested, 0) + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + + const result = await ctx.tools.execute({ + callId: CallId('read-with-unreadable-nested-instruction'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent: stubAgent(root), + }) + + expect(result.isError).toBe(false) + expect(result.additionalContexts).toBeUndefined() + await chmod(nested, 0o600) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('preserves nested and downstream post-execute contexts as separate entries', async () => { + const root = await tempRepo() + const home = await tempRepo() + 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') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + ctx.on('tools/post-execute', async () => ({ + kind: 'accept' as const, + content: [{ type: 'text' as const, text: 'downstream replacement' }], + additionalContexts: [{ + content: [{ type: 'text' as const, text: 'downstream context' }], + source: { kind: 'plugin' as const, plugin: 'downstream' }, + }], + })) + + const result = await ctx.tools.execute({ + callId: CallId('read-with-downstream'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent: stubAgent(root), + }) + + expect(blocksText(result.content)).toBe('downstream replacement') + expect(result.additionalContexts).toHaveLength(2) + expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) + expect(workspaceContextOf(result)?.envelope).toBe('raw') + expect(workspaceContextOf(result)?.meta).toMatchObject({ + kind: 'workspace-instructions', + changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }], + }) + expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule') + expect(blocksText(workspaceContextOf(result)?.content)).not.toContain('downstream context') + expect(result.additionalContexts?.[1]).toEqual({ + content: [{ type: 'text', text: 'downstream context' }], + source: { kind: 'plugin', plugin: 'downstream' }, + }) + const agent = stubAgent(root) + appendAdditionalContexts(agent, result) + expect(blocksText(agent.session.deriveMessages()[1]?.content)).toContain('\ndownstream context\n') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('does not attach discovered instructions when a downstream listener blocks the tool call', async () => { + const root = await tempRepo() + const home = await tempRepo() + 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') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + ctx.on('tools/post-execute', async () => ({ + kind: 'block' as const, + feedback: [{ type: 'text' as const, text: 'blocked downstream' }], + })) + + const result = await ctx.tools.execute({ + callId: CallId('read-blocked-downstream'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent: stubAgent(root), + }) + + // The pipeline rejected this touch, so no workspace instructions from it + // should reach the model, and the block feedback must survive unchanged. + expect(result.isError).toBe(true) + expect(blocksText(result.content)).toBe('blocked downstream') + expect(result.additionalContexts).toBeUndefined() + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + 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() + 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') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + const result = { + callId: CallId('manual'), + content: [{ type: 'text' as const, text: 'manual result' }], + isError: false, + } + const cases = [ + { name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, agent: undefined }, + { name: 'bash', arguments: { file_path: 'pkg/deep/file.txt' }, agent }, + { name: 'read', arguments: null, agent }, + { name: 'read', arguments: {}, agent }, + { name: 'read', arguments: { file_path: 1 }, agent }, + { name: 'read', arguments: { file_path: ' ' }, agent }, + ] + + for (const item of cases) { + const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({ + callId: CallId(`manual-${item.name}-${cases.indexOf(item)}`), + name: item.name, + arguments: item.arguments, + ...item.agent === undefined ? {} : { agent: item.agent }, + }), result, async () => ({ kind: 'accept' as const })) + expect(decision).toEqual({ kind: 'accept' }) + } + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('does not attach nested instructions when the byte budget is disabled', async () => { + const root = await tempRepo() + const home = await tempRepo() + 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') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 0 }) + + const result = await ctx.tools.execute({ + callId: CallId('read-with-disabled-budget'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent: stubAgent(root), + }) + + expect(result.isError).toBe(false) + expect(result.additionalContexts).toBeUndefined() + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('does not attach nested instructions after a failed file read', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + + const result = await ctx.tools.execute({ + callId: CallId('read-missing'), + name: 'read', + arguments: { file_path: 'pkg/missing.txt' }, + agent: stubAgent(root), + }) + + expect(result.isError).toBe(true) + expect(result.additionalContexts).toBeUndefined() + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it('cleans up its tools/post-execute listener when the plugin fiber is disposed', async () => { + const root = await tempRepo() + const home = await tempRepo() + 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') + const ctx = new Context() + const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await fiber.dispose() + + const result = await ctx.tools.execute({ + callId: CallId('read-after-dispose'), + name: 'read', + arguments: { file_path: 'pkg/deep/file.txt' }, + agent: stubAgent(root), + }) + + expect(result.isError).toBe(false) + expect(result.additionalContexts).toBeUndefined() + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) +}) + +describe('workspace context pending state', () => { + it('leaves pending transitions from other or untracked steps untouched', () => { + const agent = stubAgent('/') + const change = (scope: string) => ({ + action: 'set' as const, scope, path: `${scope}/AGENTS.md`, digest: scope, + }) + const pending = new WeakMap>([[ + agent.session, + new Map([ + ['untracked', { change: change('untracked'), afterSeq: 0 }], + ['other-turn', { change: change('other-turn'), afterSeq: 0, step: { turn: 2, step: 1 } }], + ['other-step', { change: change('other-step'), afterSeq: 0, step: { turn: 1, step: 2 } }], + ['current', { change: change('current'), afterSeq: 0, step: { turn: 1, step: 1 } }], + ]), + ]]) + const versions: InstructionVersionCache = new WeakMap() + const ended = agent.session.append('step/end', { turn: 1, step: 1 }) + + observeInstructionSessionEvent(agent.session, ended, pending, versions) + + expect([...pending.get(agent.session)?.keys() ?? []]).toEqual(['untracked', 'other-turn', 'other-step']) + }) + + it('confirms a pending transition only when its matching workspace context reaches the log', () => { + const agent = stubAgent('/') + const pending = new WeakMap>() + const versions: InstructionVersionCache = new WeakMap() + const [change] = commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending) + expect(change).toBeDefined() + versions.set(agent.session, new Map([['pkg', { + path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one', + }]])) + + const unrelated = agent.session.append('context/message', { + content: [], source: { kind: 'plugin', plugin: 'other' }, + }, { surfaceOp: 'append' }) + observeInstructionSessionEvent(agent.session, unrelated, pending, versions) + expect(pending.get(agent.session)?.has('pkg')).toBe(true) + + const otherContext = workspaceChangeContext('other', 'other') + const otherWorkspaceEvent = agent.session.append('context/message', { + content: otherContext.content, + source: otherContext.source, + ...otherContext.envelope !== undefined ? { envelope: otherContext.envelope } : {}, + ...otherContext.meta !== undefined ? { meta: otherContext.meta } : {}, + }, { surfaceOp: 'append' }) + observeInstructionSessionEvent(agent.session, otherWorkspaceEvent, pending, versions) + expect(pending.get(agent.session)?.has('pkg')).toBe(true) + + const context = workspaceChangeContext('pkg', 'one') + const confirmed = agent.session.append('context/message', { + content: context.content, + source: context.source, + ...context.envelope !== undefined ? { envelope: context.envelope } : {}, + ...context.meta !== undefined ? { meta: context.meta } : {}, + }, { surfaceOp: 'append' }) + observeInstructionSessionEvent(agent.session, confirmed, pending, versions) + + expect(pending.has(agent.session)).toBe(false) + expect(versions.get(agent.session)?.has('pkg')).toBe(true) + }) + + it('discards pending state and its version fast path when the owning step closes first', () => { + const agent = stubAgent('/') + const pending = new WeakMap>() + const versions: InstructionVersionCache = new WeakMap() + agent.session.append('step/start', { turn: 1, step: 1 }) + commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending) + versions.set(agent.session, new Map([['pkg', { + path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one', + }]])) + + const ended = agent.session.append('step/end', { turn: 1, step: 1 }) + observeInstructionSessionEvent(agent.session, ended, pending, versions) + + expect(pending.has(agent.session)).toBe(false) + expect(versions.has(agent.session)).toBe(false) + }) + + 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) + expect(typeof workspaceContext.apply).toBe('function') + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(workspaceContext) as Record + expect(unwrapped).toBe(workspaceContext) + expect(unwrapped.name).toBe('workspace-context') + expect(unwrapped.Config).toBeDefined() + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/context/workspace-context/tsconfig.json b/packages/context/workspace-context/tsconfig.json new file mode 100644 index 0000000000..b4807ded65 --- /dev/null +++ b/packages/context/workspace-context/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../fs/fs" + }, + { + "path": "../../util/paths" + } + ] +} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 51b4a5155f..e360c6c8e1 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -110,8 +110,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'fs', summary: 'Abstract filesystem provider.', 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', 'abstract streamText(target: FsTarget, signal?: AbortSignal): Promise>', 'abstract listDir(target: FsTarget, signal?: AbortSignal): Promise', @@ -503,7 +504,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: AgentId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: SendOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n}', + declaration: 'export interface Agent {\n readonly id: AgentId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n}', }, { name: 'AgentFactory', @@ -649,6 +650,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ContentBlockType', declaration: 'export type ContentBlockType = keyof ContentBlockMap;', }, + { + name: 'ContextEnvelope', + declaration: 'export type ContextEnvelope = \'context\' | \'raw\';', + }, { name: 'CreateAgentOptions', declaration: 'export interface CreateAgentOptions {\n readonly agentId: AgentId;\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', @@ -697,6 +702,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'FsInfo', declaration: 'export interface FsInfo {\n version: FsVersion;\n type: \'file\' | \'directory\' | \'other\';\n size?: number;\n}', }, + { + name: 'FsPathInfo', + declaration: 'export interface FsPathInfo {\n version: FsVersion;\n type: \'file\' | \'directory\' | \'symlink\' | \'other\';\n size?: number;\n}', + }, { name: 'FsTarget', declaration: 'export interface FsTarget {\n targetKey: FsTargetKey;\n displayPath: string;\n}', @@ -731,7 +740,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'HookContext', - declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n}', + declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n}', + }, + { + name: 'InjectOptions', + declaration: 'export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n}', + }, + { + name: 'JsonValue', + declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};', }, { name: 'Message', @@ -795,7 +812,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: E /* …truncated — full shape in source */', + declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n todos /* …truncated — full shape in source */', }, { name: 'SessionEventReadRequest', @@ -969,10 +986,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TerminalResultView', declaration: 'export interface TerminalResultView {\n card: \'terminal\';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n}', }, - { - name: 'TodoItem', - declaration: 'export interface TodoItem {\n content: string;\n status: \'pending\' | \'in_progress\' | \'completed\';\n}', - }, { name: 'TokenUsage', declaration: 'export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n}', @@ -991,7 +1004,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolDefinition', - declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolExecution): Promise;\n timeoutMs?: number;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}', + declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}', }, { name: 'ToolErrorInfo', @@ -1011,7 +1024,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionResult', - declaration: 'export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContext?: HookContext;\n meta?: unknown;\n}', + declaration: 'export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n}', }, { name: 'ToolExecutionToken', @@ -1041,6 +1054,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolResultView', declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;', }, + { + name: 'ToolRunContext', + declaration: 'export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n}', + }, { name: 'ToolSchema', declaration: 'export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n}', diff --git a/packages/core/README.md b/packages/core/README.md index 921591d85e..d2d0c60fda 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -15,4 +15,4 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co `agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable. -The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door. +The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + workspace-context + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door. diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index a2288c65b7..2cd7f16682 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' -import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent' +import type { AgentId, AgentOptions, AgentStatus, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' @@ -225,14 +225,20 @@ export class ReactLoopAgent implements Agent { agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info) } - inject(content: ContentBlock[], options?: SendOptions): void { + inject(content: ContentBlock[], options?: InjectOptions): void { this.assertNotDisposed() const source = this.resolveSource(options) + const context = { + content, + source, + ...options?.envelope !== undefined ? { envelope: options.envelope } : {}, + ...options?.meta !== undefined ? { meta: options.meta } : {}, + } if (isTurnOpen(this.session)) { // A turn is open in the LOG (decided from the log, not agent status — // status can be `running` with no turn open): the context/message is // turn-enclosed by that turn, so append it directly. - this.session.append('context/message', { content, source }, { surfaceOp: 'append' }) + this.session.append('context/message', context, { surfaceOp: 'append' }) return } // No turn open: wrap the injection in a one-shot turn so every event stays @@ -244,7 +250,7 @@ export class ReactLoopAgent implements Agent { // are contained by Session and cannot create a false append failure. try { this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) - this.session.append('context/message', { content, source }, { surfaceOp: 'append' }) + this.session.append('context/message', context, { surfaceOp: 'append' }) } finally { // Close the turn if turn/start made it into the log. A pre-commit veto // must escape rather than being mistaken for a committed turn/end. diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index ebe528c5e0..b26d09e5c1 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -217,10 +217,15 @@ async function runTurn( // `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them. const content = promptDecision.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 (promptDecision.additionalContext) { - agent.inject(promptDecision.additionalContext.content, { source: promptDecision.additionalContext.source }) + // 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 promptDecision.additionalContexts ?? []) { + agent.inject(context.content, { + source: context.source, + ...context.envelope !== undefined ? { envelope: context.envelope } : {}, + ...context.meta !== undefined ? { meta: context.meta } : {}, + }) } } @@ -567,7 +572,7 @@ async function runStep( // Persist tool-owned presentation data for replay. ...result.meta !== undefined ? { meta: result.meta } : {}, }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] }) - if (result.additionalContext) pendingContext.push(result.additionalContext) + pendingContext.push(...result.additionalContexts ?? []) // The signal may flip while the tool is awaited. /* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -577,7 +582,11 @@ async function runStep( // Append buffered context after the complete result batch. for (const context of pendingContext) { - agent.inject(context.content, { source: context.source }) + agent.inject(context.content, { + source: context.source, + ...context.envelope !== undefined ? { envelope: context.envelope } : {}, + ...context.meta !== undefined ? { meta: context.meta } : {}, + }) } return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish } diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index ad0ea7d199..57b773de8a 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,15 +91,21 @@ 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' }) + const meta = { kind: 'prompt-context', version: 1 } ctx.on('agent/prompt-submit', async (): Promise => ({ kind: 'allow', - additionalContext: { content: [{ type: 'text', text: 'extra ctx' }], source: { kind: 'plugin', plugin: 'test' } }, + additionalContexts: [{ + content: [{ type: 'text', text: 'extra ctx' }], + source: { kind: 'plugin', plugin: 'test' }, + envelope: 'raw', + meta, + }], })) send(agent, 'go') @@ -109,14 +115,16 @@ describe('agent/prompt-submit', () => { const userMsg = log.find(e => e.type === 'user/message') const ctxMsg = log.find(e => e.type === 'context/message') expect(userMsg).toBeDefined() - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }]) + expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }]) expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) + expect(ctxMsg?.type === 'context/message' && ctxMsg.data.envelope).toBe('raw') + expect(ctxMsg?.type === 'context/message' && ctxMsg.data.meta).toEqual(meta) // both the prompt and the injected context reach the model const sent = JSON.stringify(adapter.requests[0]!.messages) 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 () => { // Prompt rewrites and injected context land before `agent/pre-step`, so a // compaction listener measures the current surface before the single derive. const adapter = new MockAdapter([textResponse('ok')]) @@ -127,7 +135,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 @@ -538,8 +546,8 @@ describe('agent/turn-continuation (ContinuationDecision)', () => { }) }) -describe('tools/post-execute additionalContext buffering across a multi-call step', () => { - it('appends each call\'s additionalContext only AFTER all tool/results, preserving adjacency', async () => { +describe('tool additionalContexts buffering across a step', () => { + it('appends each call\'s contexts only AFTER all tool/results, preserving adjacency', async () => { // One assistant step with TWO tool calls; the second model response stops. const twoCalls = [ { type: 'block-start' as const, index: 0, blockType: 'tool-call' as const }, @@ -557,9 +565,17 @@ describe('tools/post-execute additionalContext buffering across a multi-call ste })) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // Each call attaches additionalContext naming itself. + // Each call attaches one context naming itself. ctx.on('tools/post-execute', async (exec, _result): Promise => - ({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } } })) + ({ + kind: 'accept', + additionalContexts: [{ + content: [{ type: 'text', text: `ctx-${exec.callId}` }], + source: { kind: 'plugin', plugin: 'p' }, + envelope: 'raw', + meta: { callId: exec.callId }, + }], + })) send(agent, 'go') await waitForIdle(ctx, agent) @@ -579,6 +595,37 @@ describe('tools/post-execute additionalContext buffering across a multi-call ste .flatMap(e => (e.type === 'context/message' ? e.data.content : [])) .map(b => (b.type === 'text' ? b.text : '')) expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2']) + const contextEvents = events(agent).filter(e => e.type === 'context/message') + expect(contextEvents.map(e => e.type === 'context/message' && e.data.envelope)).toEqual(['raw', 'raw']) + expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }]) + }) + + it('appends multiple contexts deferred by one composite tool after its outer result', async () => { + const adapter = new MockAdapter([toolCallResponse('c1', 'composite', {}), textResponse('done')]) + const ctx = await harness(adapter) + ctx.tools.register(defineTool({ + name: 'composite', description: 'composite', parameters: {}, + async execute(_args, exec) { + exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } }) + exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, envelope: 'raw', meta: { order: 2 } }) + return [{ type: 'text', text: 'outer result' }] + }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + const log = events(agent) + const resultIndex = log.findIndex(event => event.type === 'tool/result') + const contextEvents = log.filter(event => event.type === 'context/message') + expect(resultIndex).toBeGreaterThanOrEqual(0) + expect(log.findIndex(event => event === contextEvents[0])).toBeGreaterThan(resultIndex) + expect(contextEvents.map(event => event.type === 'context/message' && event.data.source)).toEqual([ + { kind: 'plugin', plugin: 'a' }, + { kind: 'plugin', plugin: 'b' }, + ]) + expect(contextEvents.map(event => event.type === 'context/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }]) }) }) @@ -638,7 +685,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se ctx.on('tools/post-execute', async (_exec, _result, next): Promise => { const decision = await next() if (decision.kind === 'accept') { - return { kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' } } } + return { kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' } }] } } return decision }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 368ce0c6f1..aff13ddd5c 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -383,6 +383,32 @@ describe('agent loop', () => { expect(flat).toContain('') }) + it('inject() can persist raw structured context without the generic context envelope', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('raw-context'), { model: 'mock' }) + const text = 'Additional instructions from: pkg/AGENTS.md' + const meta = { + kind: 'workspace-instructions', + version: 1, + changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 'abc123' }], + } + + agent.inject([{ type: 'text', text }], { + source: { kind: 'plugin', plugin: 'workspace-context' }, + envelope: 'raw', + meta, + }) + send(agent, 'go') + await waitForIdle(ctx, agent) + + const contextEvent = agent.session.events.find(event => event.type === 'context/message') + expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ envelope: 'raw', meta }) + const requestText = JSON.stringify(adapter.requests[0]!.messages) + expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md') + expect(requestText).not.toContain(' { const adapter = new MockAdapter([ toolCallResponse('c1', 'noticer', {}, 'calling'), diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index e1e5b27f22..8e86700835 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -33,6 +33,8 @@ The loop plugin registers `AgentFactory`, keeping consumers independent of its c 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`) @@ -41,7 +43,7 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue one independent FIFO item. Unless broad cancellation or disposal clears it before turn start, that item becomes the sole ordinary message in its turn; the next item waits for the preceding turn's durability checkpoint. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle -- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) +- `agent.inject(content, options?)` — inject in-session context (`context/message` event); the next request sees it. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 62d5b2695e..1bd416ccfc 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -22,7 +22,7 @@ export type AgentId = Branded<'AgentId'> export function AgentId(id: string): AgentId { return id as AgentId } -import type { Session } from '@deepseek-ai/dsh-session' +import type { ContextEnvelope, JsonValue, Session } from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { @@ -42,6 +42,14 @@ export interface SendOptions { source?: MessageSource } +/** Options specific to durable synthetic context injection. */ +export interface InjectOptions extends SendOptions { + /** Keep the canonical context tag, or send caller-owned framing verbatim. */ + envelope?: ContextEnvelope + /** Opaque JSON state retained in the session event but hidden from the model. */ + meta?: JsonValue +} + /** * An agent's lifecycle state, emitted on every transition as `agent/status`: * `idle` (parked, waiting for queued work), `running` (a turn is in progress), @@ -54,21 +62,25 @@ export type AgentStatus = 'idle' | 'running' | 'disposed' export interface HookContext { content: ContentBlock[] source: MessageSource + /** Keep the canonical context tag, or use caller-owned framing verbatim. */ + envelope?: ContextEnvelope + /** Opaque JSON state retained in the session event but hidden from the model. */ + meta?: JsonValue } /** - * Prompt interception result. `allow.content` replaces the prompt and - * `additionalContext` becomes a separate context message. `block` records a - * durable `prompt/blocked` and ends that prompt's zero-step turn as rejected. + * Prompt interception result. `allow.content` replaces the prompt and each + * `additionalContexts` entry becomes a separate context message. `block` + * records a durable `prompt/blocked` and ends that prompt's zero-step turn as rejected. */ export type PromptDecision = - | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext } + | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'block'; reason: string } /** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ 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 @@ -108,7 +120,7 @@ export interface Agent { * turn joins it at the current log position. Disposal awaits idle checkpoints; * flush failures are reported through `agent/error`, not thrown to the caller. */ - inject(content: ContentBlock[], options?: SendOptions): void + inject(content: ContentBlock[], options?: InjectOptions): void /** * Clear all queued and steering work, including items waiting to start, and diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 7e201ab647..46d79f02e7 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -56,6 +56,8 @@ Durable values need one accepted representation, not a check followed by a secon `request/header` and `request/header-delta` make the non-history request envelope reconstructable from the log. `foldRequestHeader()` reconstructs the active header, `diffHeader()` encodes changes, and `applyHeaderDelta()` replays them; unsupported deltas fall back to a full snapshot. `messagePrefix` remains separate from derived history. See the [reconstructable-requests RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md). +`context/message` defaults to the canonical tagged context projection. A producer may set `envelope: 'raw'` when its `content` already contains the complete model-facing frame, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. + ### Session event vocabulary (`types.ts`) The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`. diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 89f1627445..6fd2875036 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' -import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' +import type { ContextEnvelope, CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import { snapshotJsonValue } from './json.ts' import { SurfaceManager, isSurfaceEligibleType } from './surface.ts' import { foldRequestHeader } from './request-header.ts' @@ -226,6 +226,22 @@ interface SessionEntry { /** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */ const attachments = new WeakMap() +/** + * Render one context contribution exactly as it will appear in model history. + * @param content - content blocks supplied by the context producer. + * @param source - attribution used by the canonical context envelope. + * @param envelope - canonical tagged framing or caller-owned raw framing. + * @returns a detached block list ready for the derived model transcript. + */ +export function renderContextContent( + content: ContentBlock[], + source: MessageSource, + envelope: ContextEnvelope = 'context', +): ContentBlock[] { + const cloned = structuredClone(content) + return envelope === 'raw' ? cloned : renderTagged('context', cloned, source) +} + /** * An event-sourced session: an append-only log of {@link SessionEvent}s. * @@ -514,8 +530,8 @@ export class Session { } } case 'context/message': { - const { content, source } = event.data - return { role: 'user', content: renderTagged('context', content, source) } + const { content, source, envelope } = event.data + return { role: 'user', content: renderContextContent(content, source, envelope) } } case 'steering/message': { const { content, source } = event.data diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 131deec325..f9de8962ac 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -1,5 +1,9 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { JsonValue } from './json.ts' + +/** Canonical context-tag framing, or caller-owned framing rendered verbatim. */ +export type ContextEnvelope = 'context' | 'raw' /** Identifies one session in the store (and its persistence artifacts). */ export type SessionId = Branded<'SessionId'> @@ -235,9 +239,16 @@ export interface SessionEventMap { /** * In-session context injection (file-change notices, subdir AGENTS.md, * skill content, cron notifications, …). Rendered into the derived history - * as tagged synthetic context — NOT a user prompt. + * as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller + * own the complete model-facing frame; `meta` is durable JSON state omitted + * from the model projection. */ - 'context/message': { content: ContentBlock[]; source: MessageSource } + 'context/message': { + content: ContentBlock[] + source: MessageSource + envelope?: ContextEnvelope + meta?: JsonValue + } /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } /** diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 2326e7b212..93f44eecf5 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -59,6 +59,28 @@ describe('Session', () => { expect(steeringMessage!.content[0]).toMatchObject({ type: 'text', text: '' }) }) + it('renders raw context without a generic envelope while preserving structured metadata', () => { + const session = new Session(SessionId('s2-raw')) + const meta = { + kind: 'workspace-instructions', + version: 1, + changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 'abc123' }], + } + session.append('context/message', { + content: [{ type: 'text', text: 'Additional instructions from: pkg/AGENTS.md' }], + source: { kind: 'plugin', plugin: 'workspace-context' }, + envelope: 'raw', + meta, + }, { surfaceOp: 'append' }) + + expect(session.deriveMessages()).toEqual([{ + role: 'user', + content: [{ type: 'text', text: 'Additional instructions from: pkg/AGENTS.md' }], + }]) + const event = session.events[0] + expect(event?.type === 'context/message' && event.data.meta).toEqual(meta) + }) + it('replays identically from a seeded event log', () => { const original = new Session(SessionId('s3')) original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 0f7974e0f9..16fba41e54 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -36,9 +36,10 @@ The live registry pipeline has three transformable waterfalls followed by the ob - `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. - `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. -- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContext?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. +- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately. +- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source, envelope, and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step. - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny. -- `PostToolDecision` — `{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns. +- `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. - `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch. - `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation"). @@ -47,7 +48,7 @@ The live registry pipeline has three transformable waterfalls followed by the ob - Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically. - `tools/pre-execute` is the reorderable allow/deny/ask gate; `ctx.tools.guard()` adds monotonic owner policy after it. - `tools/execute` wraps normalized core dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal. -- `tools/post-execute` may replace content, block with feedback, or attach context; `tools/result` observes the immutable final outcome. +- `tools/post-execute` may replace content, block with feedback, or attach ordered contexts; `tools/result` observes the immutable final outcome. - Exact signatures and ordering live in the generated [event catalog](../../../docs/cordis-catalog/events.md) and [pipeline](../../../docs/tool-execution-pipeline.md). - MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas. @@ -101,7 +102,11 @@ Returning `undefined` selects generic fallback. Presenters depend only on their ### Code Mode -Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and mid-run `additionalContext` is omitted to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`. +Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`. + +- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw. +- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `:code:`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/envelope/meta even when the program later fails. +- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. ## Model Experience diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 012182db1b..0a65c9e434 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -5,6 +5,7 @@ * @module @deepseek-ai/dsh-tools/src/code-mode */ +import { parse } from 'node:path' import { inspect } from 'node:util' import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' @@ -16,11 +17,19 @@ import type { ToolDefinition, ToolRegistry } from './index.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { /** - * One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the - * deterministic sub-call id (`:code:`), the tool `name` with its - * JSON-normalized `arguments` — the exact value dispatched, normalized before dispatch, so - * this append can never fail on payload shape — whether the sub-call errored, and a - * bounded `resultSummary` of its model-facing text. + * One bridged sub-dispatch from a `run_code` program: the parent + * `run_code` call id, the deterministic sub-call id + * (`:code:`), the tool `name` with its JSON-normalized + * `arguments` — the exact value dispatched, normalized BEFORE dispatch, + * so this append can never fail on payload shape — whether the sub-call + * errored, and a bounded `resultSummary` of its model-facing text. Before + * bounding, occurrences of a non-root session workspace path are + * normalized to `.` so host-specific absolute path lengths cannot change + * the summary. + * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter + * model context; persistence and UIs get every call. Appended inside the + * parent `run_code`'s execution (the bridge drains its queue before + * returning), so the turn-enclosure invariant holds by construction. */ 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string } } @@ -70,9 +79,12 @@ function textOf(content: ContentBlock[]): string { .join('\n') } -/** Bound a sub-call's model-facing text for the log event's `resultSummary`. */ -function summarize(text: string): string { - return text.length > SUMMARY_MAX_CHARS ? `${text.slice(0, SUMMARY_MAX_CHARS)}…` : text +/** Normalize workspace paths, then bound a sub-call's model-facing text for its durable log summary. */ +function summarize(text: string, cwd: string | undefined): string { + const stableText = cwd === undefined || cwd === parse(cwd).root + ? text + : text.replaceAll(cwd, '.') + return stableText.length > SUMMARY_MAX_CHARS ? `${stableText.slice(0, SUMMARY_MAX_CHARS)}…` : stableText } /** @@ -189,10 +201,10 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => parent: exec.token, signal: runController.signal, }) + for (const context of result.additionalContexts ?? []) { + exec.deferContext(context) + } const text = textOf(result.content) - // Sub-call `additionalContext` is deliberately DROPPED here: the loop's buffering - // (append after the step's tool/results) has no safe analogue from inside a running - // run_code — injecting now would break tool-call/result adjacency. exec.agent?.session.append('tool/code-dispatch', { parentCallId: exec.callId, subCallId, @@ -202,7 +214,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => // this record from what it actually received. arguments: normalized.logged, isError: result.isError, - resultSummary: summarize(text), + resultSummary: summarize(text, exec.agent.session.header.cwd), }) return { text, isError: result.isError } }) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index d0c75e8035..4aeebe1d99 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -125,7 +125,7 @@ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta /** A registered tool: its schema plus the execution function. */ export interface ToolDefinition extends ToolSchema { - execute(args: unknown, exec: ToolExecution): Promise + execute(args: unknown, exec: ToolRunContext): Promise /** * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. * Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it @@ -207,6 +207,21 @@ export interface ToolExecution extends ToolExecutionInput { readonly token: ToolExecutionToken } +/** + * Runtime context handed to a tool implementation after the registry has + * accepted a {@link ToolExecution}. A composite tool uses + * {@link deferContext} to ferry context produced by nested dispatches back to + * the outer result; the loop appends it only after the outer `tool/result`. + */ +export interface ToolRunContext extends ToolExecution { + /** + * Defer one nested-dispatch context until this tool's final result reaches + * the agent loop. Contexts retain their individual source, envelope, and + * metadata and are emitted in call order. + */ + deferContext(context: HookContext): void +} + /** Structured error metadata for a failed tool call (alongside the model-facing text). */ export interface ToolErrorInfo { name: string @@ -240,7 +255,7 @@ export interface ToolExecutionResult { * Model-facing context for the next request, separate from this tool result. * The loop buffers it until all step results are logged, preserving pairing. */ - additionalContext?: HookContext + additionalContexts?: HookContext[] /** * The tool-private presentation payload from a successful `execute` (the object * return form). Threaded onto the `tool/result` session event and back into @@ -266,8 +281,8 @@ export type PreToolDecision = * request, or block by turning corrective feedback into an error result. */ export type PostToolDecision = - | { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext } - | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } + | { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } /** * Best-effort human-readable message from an arbitrary thrown value: Error @@ -677,6 +692,7 @@ export class ToolRegistry extends Service { * @returns the materialized final result. */ async execute(exec: ToolExecutionInput): Promise { + const deferredContexts: HookContext[] = [] const token = createExecutionToken() const callId = exec.callId const name = exec.name @@ -690,8 +706,11 @@ export class ToolRegistry extends Service { ...agent !== undefined ? { agent } : {}, ...parent !== undefined ? { parent } : {}, ...signal !== undefined ? { signal } : {}, + deferContext(context: HookContext): void { + deferredContexts.push(context) + }, } - let execution: ToolExecution + let execution: ToolRunContext try { const detached = snapshotJsonValue(exec.arguments) if (detached === undefined) { @@ -709,7 +728,7 @@ export class ToolRegistry extends Service { } let result: ToolExecutionResult try { - result = this.materializeFinalResult(await this.executePipeline(execution)) + result = this.materializeFinalResult(await this.executePipeline(execution, deferredContexts)) } catch (error: unknown) { // Outer backstop: a throwing pre/post-execute listener, guard, or the // waterfall machinery becomes an isError result, never a turn failure. @@ -720,7 +739,7 @@ export class ToolRegistry extends Service { } /** Run the transformable pipeline; {@link execute} owns final normalization and notification. */ - private async executePipeline(exec: ToolExecution): Promise { + private async executePipeline(exec: ToolRunContext, deferredContexts: HookContext[]): Promise { // --- Gate: tools/pre-execute. An `ask` resolves through the optional // approval seam (or degrades to deny) before the monotonic guards run. The // carrier keys dispatch by exec.agent, so an `agent.ctx` listener gates only @@ -774,7 +793,16 @@ export class ToolRegistry extends Service { } }, ) - return await this.postExecute(exec, result) + const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0 + ? result + : { + ...result, + additionalContexts: [ + ...deferredContexts, + ...result.additionalContexts ?? [], + ], + } + return await this.postExecute(exec, resultWithDeferredContexts) } /** Notify final-result observers without giving them a mutation/error channel into the outcome. */ @@ -836,8 +864,11 @@ export class ToolRegistry extends Service { * Run the `tools/post-execute` waterfall over a dispatched `result` and apply * its {@link PostToolDecision}: `accept` keeps the call successful (replacing * `content` when given), `block` turns it into an `isError` whose content is - * the corrective `feedback`. Either decision may attach `additionalContext`, - * which is ferried on the returned result for the loop's per-step buffer. + * the corrective `feedback`. Either decision may attach `additionalContexts`, + * which are ferried on the returned result for the loop's per-step buffer. + * Context deferred by the tool body survives an accepted result but is + * discarded when the outer call is blocked; a block exposes only context the + * blocking decision explicitly supplied. * Runs inside `execute`'s outer try/catch (a throwing listener → isError). */ private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise { @@ -845,19 +876,24 @@ export class ToolRegistry extends Service { scopeTarget(this, exec.agent), 'tools/post-execute', exec, result, () => Promise.resolve({ kind: 'accept' }), ) - const additionalContext = decision.additionalContext + const decisionContexts = decision.additionalContexts ?? [] if (decision.kind === 'block') { return { content: decision.feedback, isError: true, - ...additionalContext ? { additionalContext } : {}, + ...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {}, } } - // Accept: replace content if supplied and preserve the dispatched outcome. + // Accept: replace content if supplied, preserve the dispatched outcome, and + // append decision contexts after contexts deferred by the tool body. + const additionalContexts = [ + ...result.additionalContexts ?? [], + ...decisionContexts, + ] return { ...result, ...decision.content ? { content: decision.content } : {}, - ...additionalContext ? { additionalContext } : {}, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, } } diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 9e671f6c73..9b8510a768 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -1,7 +1,7 @@ /** Typed tool-parameter DSL with argument inference and JSON Schema output. @module dsh-tools/schema */ import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' -import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts' +import type { ToolDefinition, ToolExecuteReturn, ToolRunContext, ToolResult } from './index.ts' import type { ToolCallView, ToolResultView } from './presentation.ts' // --------------------------------------------------------------------------- @@ -289,7 +289,7 @@ export interface DefineToolOptions { * content only) or a `{ content, meta }` object to also attach a tool-private * presentation payload (see {@link ToolExecuteReturn}). */ - execute(args: InferArgs, exec: ToolExecution): Promise + execute(args: InferArgs, exec: ToolRunContext): Promise /** * Optional: how to present the PENDING state of one call in a UI (an editor * tool-call card, a CLI log line). `args` is the typed, schema-validated @@ -333,7 +333,7 @@ export function defineTool(options: DefineToolOptions): description: options.description, parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - async execute(args: unknown, exec: ToolExecution): Promise { + async execute(args: unknown, exec: ToolRunContext): Promise { // Validate the model-generated args before the typed body runs. On // mismatch we throw ToolArgsError; the registry turns it into an // isError result so the model can self-correct. After this guard, the diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 54575403cf..d4660f7ed7 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -82,10 +82,11 @@ function registerEcho(ctx: Context, name = 'echo'): unknown[] { } /** A structural fake of the owning agent: captures session appends. */ -function fakeAgent(): { agent: Agent; events: { type: string; data: unknown }[] } { +function fakeAgent(options: { cwd?: string } = { cwd: '/workspace' }): { agent: Agent; events: { type: string; data: unknown }[] } { const events: { type: string; data: unknown }[] = [] const agent = { session: { + header: options.cwd === undefined ? {} : { cwd: options.cwd }, append: (type: string, data: unknown) => { events.push({ type, data }) }, }, } as unknown as Agent @@ -477,27 +478,71 @@ describe('the run_code dispatch bridge', () => { expect(dispatch.arguments).toEqual({ value: 'x', when: '1970-01-01T00:00:00.000Z' }) }) - it('suppresses sub-call additionalContext (deliberately; pinned)', async () => { + it('defers sub-call additionalContexts onto the outer run_code result', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) registerEcho(ctx) ctx.on('tools/post-execute', (exec, _result, next): Promise => { if (exec.name === 'echo') { return Promise.resolve({ kind: 'accept' as const, - additionalContext: { content: [{ type: 'text' as const, text: 'context for the next request' }], source: { kind: 'plugin' as const, plugin: 'test' } }, + additionalContexts: [{ + content: [{ type: 'text' as const, text: `context for ${exec.callId}` }], + source: { kind: 'plugin' as const, plugin: 'test' }, + envelope: 'raw' as const, + meta: { callId: exec.callId }, + }], }) } return next() }) runtime.behavior = async (request) => { await request.bindings[0]!.functions.echo!({ value: 'x' }) + await request.bindings[0]!.functions.echo!({ value: 'y' }) return { logs: [], value: 'done' } } const result = await runCode(ctx, 'program') expect(result.isError).toBe(false) - // The sub-call's context has no safe outlet mid-run; the parent result - // must not carry it either. - expect(result.additionalContext).toBeUndefined() + expect(result.additionalContexts).toEqual([ + { + content: [{ type: 'text', text: 'context for call-1:code:1' }], + source: { kind: 'plugin', plugin: 'test' }, + envelope: 'raw', + meta: { callId: 'call-1:code:1' }, + }, + { + content: [{ type: 'text', text: 'context for call-1:code:2' }], + source: { kind: 'plugin', plugin: 'test' }, + envelope: 'raw', + meta: { callId: 'call-1:code:2' }, + }, + ]) + }) + + it('keeps sub-call contexts when run_code fails after the nested dispatch', async () => { + const { ctx, runtime } = await setup({ mode: 'both' }) + registerEcho(ctx) + ctx.on('tools/post-execute', (exec, _result, next): Promise => { + if (exec.name !== 'echo') return next() + return Promise.resolve({ + kind: 'accept', + additionalContexts: [{ + content: [{ type: 'text', text: 'nested context' }], + source: { kind: 'plugin', plugin: 'test' }, + }], + }) + }) + runtime.behavior = async (request) => { + await request.bindings[0]!.functions.echo!({ value: 'x' }) + return { logs: [], error: { kind: 'exception', message: 'program failed later' } } + } + + const result = await runCode(ctx, 'program') + + expect(result.isError).toBe(true) + expect(result.additionalContexts).toEqual([{ + content: [{ type: 'text', text: 'nested context' }], + source: { kind: 'plugin', plugin: 'test' }, + }]) }) it('converts a failed run into a structured isError result carrying kind, message, and captured logs', async () => { @@ -671,6 +716,52 @@ describe('the run_code dispatch bridge', () => { expect(dispatch.resultSummary.endsWith('…')).toBe(true) }) + it('normalizes the session workspace root before bounding durable result summaries', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + ctx.tools.register(defineTool({ + name: 'workspace_path', + description: 'Return a path beneath the session workspace.', + parameters: {}, + execute(_args, exec) { + const cwd = exec.agent?.session.header.cwd ?? '' + return Promise.resolve([{ type: 'text' as const, text: `${cwd}/nested/task.txt\n${'x'.repeat(240)}` }]) + }, + })) + runtime.behavior = async request => ({ + logs: [], + value: await request.bindings[0]!.functions.workspace_path!({}), + }) + + const short = fakeAgent({ cwd: '/tmp/workspace' }) + const long = fakeAgent({ cwd: `/tmp/${'long-segment/'.repeat(30)}workspace` }) + const shortResult = await runCode(ctx, 'program', { agent: short.agent }) + const longResult = await runCode(ctx, 'program', { agent: long.agent }) + const shortDispatch = short.events[0]!.data as SessionEventMap['tool/code-dispatch'] + const longDispatch = long.events[0]!.data as SessionEventMap['tool/code-dispatch'] + + expect(shortResult.content).not.toEqual(longResult.content) + expect(shortDispatch.resultSummary).toBe(longDispatch.resultSummary) + expect(shortDispatch.resultSummary).toHaveLength(201) + expect(shortDispatch.resultSummary).toMatch(/^\.\/nested\/task\.txt<\/path>\n.+…$/) + }) + + it('leaves result summaries unchanged when a session cwd is absent or is the filesystem root', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + registerEcho(ctx) + runtime.behavior = async request => ({ + logs: [], + value: await request.bindings[0]!.functions.echo!({ value: '/workspace/value' }), + }) + + const absent = fakeAgent({}) + const root = fakeAgent({ cwd: '/' }) + await runCode(ctx, 'program', { agent: absent.agent }) + await runCode(ctx, 'program', { agent: root.agent }) + + expect((absent.events[0]!.data as SessionEventMap['tool/code-dispatch']).resultSummary).toBe('echo:/workspace/value') + expect((root.events[0]!.data as SessionEventMap['tool/code-dispatch']).resultSummary).toBe('echo:/workspace/value') + }) + it('rejects undefined, JSON-throwing, and JSON-unrepresentable binding arguments BEFORE dispatch', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const calls = registerEcho(ctx) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 59bd34e7ab..3de73c3326 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -348,7 +348,7 @@ describe('ToolRegistry', () => { expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' }) }) - it('a block decision can ALSO attach additionalContext', async () => { + it('a block decision can ALSO attach additionalContexts', async () => { const ctx = await setup() ctx.tools.register(echoTool) @@ -356,24 +356,95 @@ describe('ToolRegistry', () => { ({ kind: 'block', feedback: [{ type: 'text', text: 'rejected' }], - additionalContext: { content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }, + additionalContexts: [{ content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }], })) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'rejected' }) - expect(result.additionalContext).toMatchObject({ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }) + expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }]) }) - it('a post-execute additionalContext rides on the result for the loop to buffer', async () => { + it('post-execute additionalContexts ride on the result for the loop to buffer', async () => { const ctx = await setup() ctx.tools.register(echoTool) ctx.on('tools/post-execute', async (_exec, _result, _next): Promise => - ({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } } })) + ({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }] })) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) - expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }) + expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }]) + }) + + it('preserves tool-deferred, execute-wrapper, and post-execute contexts in order', async () => { + const ctx = await setup() + ctx.tools.register(defineTool({ + name: 'composite', + description: 'composite', + parameters: {}, + async execute(_args, exec) { + exec.deferContext({ content: [{ type: 'text', text: 'nested-1' }], source: { kind: 'plugin', plugin: 'nested-1' }, meta: { n: 1 } }) + exec.deferContext({ content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' }, envelope: 'raw' }) + return [{ type: 'text', text: 'done' }] + }, + })) + ctx.on('tools/execute', async (_exec, next) => { + const result = await next() + return { + ...result, + additionalContexts: [ + ...result.additionalContexts ?? [], + { content: [{ type: 'text', text: 'wrapper' }], source: { kind: 'plugin', plugin: 'wrapper' } }, + ], + } + }) + ctx.on('tools/post-execute', async (_exec, _result, next): Promise => { + const downstream = await next() + return { + ...downstream, + additionalContexts: [ + { content: [{ type: 'text', text: 'post' }], source: { kind: 'plugin', plugin: 'post' } }, + ...downstream.additionalContexts ?? [], + ], + } + }) + + const result = await ctx.tools.execute({ callId: CallId('composite'), name: 'composite', arguments: {} }) + + expect(result.additionalContexts?.map(context => context.source)).toEqual([ + { kind: 'plugin', plugin: 'nested-1' }, + { kind: 'plugin', plugin: 'nested-2' }, + { kind: 'plugin', plugin: 'wrapper' }, + { kind: 'plugin', plugin: 'post' }, + ]) + expect(result.additionalContexts?.[0]?.meta).toEqual({ n: 1 }) + expect(result.additionalContexts?.[1]?.envelope).toBe('raw') + }) + + it('keeps deferred contexts when a composite tool throws, but drops them when the outer call is blocked', async () => { + const ctx = await setup() + ctx.tools.register(defineTool({ + name: 'failing-composite', + description: 'failing composite', + parameters: {}, + async execute(_args, exec) { + exec.deferContext({ content: [{ type: 'text', text: 'nested' }], source: { kind: 'plugin', plugin: 'nested' } }) + throw new Error('outer failure') + }, + })) + + const failed = await ctx.tools.execute({ callId: CallId('failed'), name: 'failing-composite', arguments: {} }) + expect(failed.isError).toBe(true) + expect(failed.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'nested' }]) + + ctx.on('tools/post-execute', async (): Promise => ({ + kind: 'block', + feedback: [{ type: 'text', text: 'blocked' }], + additionalContexts: [{ content: [{ type: 'text', text: 'block-only' }], source: { kind: 'plugin', plugin: 'blocker' } }], + })) + const blocked = await ctx.tools.execute({ callId: CallId('blocked'), name: 'failing-composite', arguments: {} }) + expect(blocked.isError).toBe(true) + expect(blocked.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'blocker' }]) }) it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => { @@ -532,25 +603,25 @@ describe('ToolRegistry', () => { expect(result.content[0]).toMatchObject({ text: 'short-circuited' }) }) - it('preserves additionalContext supplied by an around-dispatch result', async () => { + it('preserves additionalContexts supplied by an around-dispatch result', async () => { const ctx = await setup() ctx.tools.register(echoTool) ctx.on('tools/execute', async () => ({ content: [{ type: 'text', text: 'short-circuited with context' }], isError: false, - additionalContext: { + additionalContexts: [{ content: [{ type: 'text', text: 'from around dispatch' }], source: { kind: 'plugin', plugin: 'test' }, - }, + }], })) const result = await ctx.tools.execute({ callId: CallId('around-context'), name: 'echo', arguments: {}, }) - expect(result.additionalContext).toEqual({ + expect(result.additionalContexts).toEqual([{ content: [{ type: 'text', text: 'from around dispatch' }], source: { kind: 'plugin', plugin: 'test' }, - }) + }]) }) it('returns an isError result when a tools/execute listener throws', async () => { diff --git a/packages/examples/README.md b/packages/examples/README.md index 5ee0206d0f..65dfa40329 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -4,7 +4,7 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | Package | npm name | Role | |---|---|---| -| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + `tool-skill` + `agent-loop`) | +| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) | | `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal stdio chat app: the spine + console logger + readline UI + a pre-created `main` agent, with a boot `bin` | | `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` | | `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client | diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 3c4851c961..28d057c0a0 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-acp": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", + "@deepseek-ai/dsh-workspace-context": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", @@ -50,6 +51,7 @@ "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "cordis": "^4.0.0-rc.7", diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 917cb128a6..e52b21b9f6 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -13,6 +13,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import * as acp from '@deepseek-ai/dsh-acp' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' +import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -39,6 +40,8 @@ export interface Config { tools?: ToolsConfig /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ @@ -61,6 +64,7 @@ export const Config: z = z.object({ // TODO(single-default-literal): share this schema default and the defensive // apply() fallback through one named constant while retaining both boundaries. persistenceRoot: z.string().default('./.sessions'), + workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, toolTasks: agentCore.ToolTasksConfigSchema, @@ -75,14 +79,7 @@ export const Config: z = z.object({ * from `model`. No logger, no `hmr` — stdout stays pure. */ export function apply(ctx: Context, config: Config): void { - ctx.plugin(agentCore, { - ...config.persona !== undefined ? { persona: config.persona } : {}, - ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, - ...config.tools !== undefined ? { tools: config.tools } : {}, - ...config.skills !== undefined ? { skills: config.skills } : {}, - ...config.toolBash !== undefined ? { toolBash: config.toolBash } : {}, - ...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {}, - }) + ctx.plugin(agentCore, agentCore.pickSpineConfig(config)) ctx.plugin(UserInteractionService) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) ctx.plugin(acp, { model: config.model }) diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 285e4756d0..34dd0054cc 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -11,7 +11,7 @@ import * as acpAgent from '../src/index.ts' /** * In-process unit coverage for the @deepseek-ai/dsh-acp-demo composition: - * mounting it brings up the agent-core spine + JSONL persistence + the ACP + * mounting it brings up the agent-spine-demo spine + JSONL persistence + the ACP * bridge in one `ctx.plugin`. Unlike the stdio app, this one loads NO * Loader-only plugin (no hmr), so it mounts in a plain Context. * @@ -70,7 +70,7 @@ async function withIsolatedSkillHomes(run: () => Promise): Promise { describe('dsh-acp-demo composition', () => { it('brings up the spine + persistence + the ACP bridge', async () => { - const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-demo-test', skills: await isolatedSkillsConfig() }) + const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-demo-test', skills: await isolatedSkillsConfig(), workspaceContext: false }) expect(ctx.get('agents')).toBeDefined() expect(ctx.get('sessions')).toBeDefined() expect(ctx.get('sessionPersistence')).toBeDefined() @@ -89,16 +89,28 @@ describe('dsh-acp-demo composition', () => { // persistenceRoot, so the runtime fallback is the one that fires. const ctx = new Context() // No persona: covers the omitted-persona forwarding branch too. - acpAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() }) + acpAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 50)) expect(ctx.get('sessionPersistence')).toBeDefined() await ctx.fiber.dispose() }) + it('forwards explicit project-instruction controls to the bundled spine', async () => { + const ctx = await mount({ + model: 'mock', + persona: 'hi', + persistenceRoot: '/tmp/dsh-acp-demo-workspace-context', + workspaceContext: false, + }) + expect(ctx.get('agents')).toBeDefined() + expect(ctx.get('agentLoop')).toBeDefined() + await ctx.fiber.dispose() + }) + it('uses default skill config when apply is called directly without skills', async () => { await withIsolatedSkillHomes(async () => { const ctx = new Context() - acpAgent.apply(ctx, { model: 'mock' }) + acpAgent.apply(ctx, { model: 'mock', workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 50)) expect(ctx.skills).toBeDefined() expect(await ctx.skills.list()).toEqual([]) @@ -106,8 +118,8 @@ describe('dsh-acp-demo composition', () => { }) }) - it('forwards skill config into agent-core', async () => { - const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) }) + it('forwards skill config into agent-spine-demo', async () => { + const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6), workspaceContext: false }) ctx.skills.register({ name: 'acp-skill', description: 'ACP skill', source: 'runtime', content: 'body' }) expect(JSON.stringify(await composePrefix(ctx))).toContain('- `acp-skill`: ACP...') await ctx.fiber.dispose() @@ -116,6 +128,7 @@ describe('dsh-acp-demo composition', () => { it('forwards bundled tool config into agent-core', async () => { const ctx = await mount({ model: 'mock', + workspaceContext: false, toolBash: { enableRunInBackground: false }, toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, skills: await isolatedSkillsConfig(), @@ -131,11 +144,12 @@ describe('dsh-acp-demo composition', () => { expect(acpAgent.Config).toBeDefined() }) - it('forwards toolOrder through agent-core to the system-prompt assembly', async () => { + it('forwards toolOrder through agent-spine-demo to the system-prompt assembly', async () => { const ctx = await mount({ model: 'mock', toolOrder: ['zulu', TOOL_ORDER_REST], persistenceRoot: '/tmp/dsh-acp-demo-test-tool-order', + workspaceContext: false, }) // The bundle's own bash tools pend on the absent `ctx.bash` executor in // this providerless mount, so register two plain tools to order. diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 9e799b29db..f612d599a9 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -30,9 +30,9 @@ const acpBin = join(repoRoot, 'packages/examples/acp-demo/lib/bin.js') const dshPackages = [ 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash', - 'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot', + 'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', - 'session-persistence/session-persistence-jsonl', 'ui/acp', 'examples/acp-demo', + 'session-persistence/session-persistence-jsonl', 'ui/acp', 'examples/acp-demo', 'util/paths', ] const vendorPackages = [ 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', @@ -86,6 +86,7 @@ async function makeConsumer(): Promise { ' config:', ' model: deepseek-v4-flash', ' persona: \'test agent\'', + ' workspaceContext: false', '', ].join('\n')) return dir diff --git a/packages/examples/acp-demo/tests/load-path.e2e.ts b/packages/examples/acp-demo/tests/load-path.e2e.ts index f1edf7832a..81988dd435 100644 --- a/packages/examples/acp-demo/tests/load-path.e2e.ts +++ b/packages/examples/acp-demo/tests/load-path.e2e.ts @@ -44,6 +44,7 @@ const CORDIS_YML = ` config: model: deepseek-v4-flash persona: 'You are a test agent.' + workspaceContext: false ` interface Spawned { diff --git a/packages/examples/acp-demo/tsconfig.json b/packages/examples/acp-demo/tsconfig.json index 6805405026..b0e537574a 100644 --- a/packages/examples/acp-demo/tsconfig.json +++ b/packages/examples/acp-demo/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../agent-spine-demo" }, + { + "path": "../../context/workspace-context" + }, { "path": "../../ui/user-interaction" }, diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index eb56f9515c..0d02d5f597 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -20,6 +20,7 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-tasks generic background-task registry @deepseek-ai/dsh-invariants dev-mode event-contract assertions @deepseek-ai/dsh-tool-bash the model-facing bash schema +@deepseek-ai/dsh-workspace-context AGENTS.md/CLAUDE.md workspace context loader @deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema @deepseek-ai/dsh-tool-tasks task_output/task_list/task_kill schemas + completion notices @deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`) @@ -41,12 +42,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, persona?, toolOrder?, tools?, skills?, toolBash?, toolTasks? } -// The schema intersects the owner schemas, -// so validation and defaulting can never drift from the owners. +// { agents?, persona?, toolOrder?, tools?, skills?, workspaceContext, toolBash?, toolTasks? } +// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. +The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. ## Why a code bundle, not a shared YAML include diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index d328043507..505e93e46f 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-spine-demo", - "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + tasks + invariants + tool-bash + tool-skill + tool-tasks + agent-loop)", + "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)", "version": "0.0.1", "private": true, "type": "module", @@ -27,6 +27,7 @@ "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-workspace-context": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-skill-local": "^0.0.1", @@ -42,8 +43,10 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index b3d6ca22bc..5652078a0f 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -1,8 +1,8 @@ /** * Default executor-less, UI-less agent spine. It bundles the common services, - * background-task registry and controls, concrete loop, local skill provider, - * and model-facing bash/skill consumers; deployments still choose the LLM - * adapter, bash executor, and presentation. + * background-task registry and controls, concrete loop, local skill and + * workspace-context providers, and model-facing bash/skill consumers; + * deployments still choose the LLM adapter, bash executor, and presentation. * The plugin intentionally exposes named exports only because Loader default * unwrapping would discard its `Config` schema (see docs/postmortem/0001). * @module @deepseek-ai/dsh-agent-spine-demo @@ -21,6 +21,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import TaskService from '@deepseek-ai/dsh-tasks' import * as invariants from '@deepseek-ai/dsh-invariants' import * as toolBash from '@deepseek-ai/dsh-tool-bash' +import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' import * as toolTasks from '@deepseek-ai/dsh-tool-tasks' import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop' @@ -43,14 +44,13 @@ export interface SkillConfig { * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt * plugin (the deployment's persona section and the explicit model-facing tool * order), the `tools` object to the tool registry (its presentation `mode`), - * and `toolBash`/`toolTasks` to the two model-facing tool plugins this bundle - * owns. Producer opt-in stays producer-local: `toolBash` configures bash only; - * future background-capable tools remain independently composed plugins. - * Every field is optional INPUT here because each owner's schema - * supplies the default (`[]` / `''` / absent — lexicographic / `native`); the - * schema is the INTERSECTION of the owners' own schemas (the registry's - * nested under its `tools` key), so validation and defaulting can never - * drift from them. + * `skills` to the skill registry/local provider/tool consumer, + * `workspaceContext` to the workspace-context loader, and + * `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * Owner schemas supply defaults for optional input; workspace context instead + * requires an explicit byte budget or `false` because it changes model-visible + * input. Producer opt-in stays producer-local: `toolBash` configures bash only; + * independently composed producers keep their own config. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ @@ -61,6 +61,8 @@ export interface Config { toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ tools?: ToolsConfig + /** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */ + workspaceContext: workspaceContext.Config | false /** Skill registry, local provider, and model-facing consumer config. */ skills?: SkillConfig /** Model-facing bash tool config, including this producer's background opt-in. */ @@ -89,19 +91,38 @@ export const Config = z.intersect([ z.object({ tools: ToolRegistry.Config, skills: SkillConfigSchema, + workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), toolBash: ToolBashConfigSchema, toolTasks: ToolTasksConfigSchema, - }), + }) as unknown as z>, ]) as unknown as z +/** + * Copy the bundle-owned fields from an app config without leaking front-door settings. + * @param config - App config containing the shared spine fields. + * @returns The fields accepted by this bundle, preserving optional absence. + */ +export function pickSpineConfig(config: Omit): Omit { + return { + ...config.persona !== undefined ? { persona: config.persona } : {}, + ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, + ...config.tools !== undefined ? { tools: config.tools } : {}, + workspaceContext: config.workspaceContext, + ...config.skills !== undefined ? { skills: config.skills } : {}, + ...config.toolBash !== undefined ? { toolBash: config.toolBash } : {}, + ...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {}, + } +} + /** * Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber; * `agent-loop` receives the forwarded `agents` list and `system-prompt` the - * forwarded `persona` and `toolOrder`. Load order is irrelevant (cordis pends - * each fiber on its `inject` until the services it needs exist), but the + * forwarded `persona` and `toolOrder`. Workspace-context receives its own + * explicitly forwarded config. Load order is irrelevant (cordis + * pends each fiber on its `inject` until the services it needs exist), but the * listing mirrors the dependency layering for readability: the LLM vocabulary - * and core registries first, then the dev tripwire and the bash tool consumer, - * then the loop that drives them. + * and core registries first, then extension plugins that wrap request/tool + * seams, then the loop that drives them. */ export function apply(ctx: Context, config: Config): void { ctx.plugin(Timer) @@ -119,6 +140,11 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(TaskService) ctx.plugin(invariants) ctx.plugin(toolBash, config.toolBash ?? {}) + if (config.workspaceContext !== false) { + ctx.plugin(workspaceContext, config.workspaceContext) + } + // Both plugins prepend session-prefix messages. Registration order is the + // rendered order, so workspace instructions must precede the skill catalog. ctx.plugin(toolSkill, config.skills?.tool ?? {}) ctx.plugin(toolTasks, config.toolTasks ?? {}) ctx.plugin(AgentLoop, { agents: config.agents ?? [] }) diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 74dbfe5484..51562f547d 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' @@ -7,6 +7,9 @@ import Loader from '@cordisjs/plugin-loader' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as agentCore from '../src/index.ts' import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { CallId, type Message } from '@deepseek-ai/dsh-llm' declare module '@deepseek-ai/dsh-tasks' { @@ -34,7 +37,7 @@ async function composePrefix(ctx: Context, cwd: string): Promise { * Loader-path guard (export shape, `unwrapExports`) is the app packages' keyless * bin smokes; here we assert the composition + config forwarding. */ -async function mount(config?: agentCore.Config, withBash = false): Promise { +async function mount(config: agentCore.Config, withBash = false): Promise { const oldDshHome = process.env.DSH_HOME const oldAgentsHome = process.env.DSH_AGENTS_HOME process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-home-')) @@ -82,9 +85,24 @@ async function withIsolatedSkillHomes(run: () => Promise): Promise { } } +function waitForMainIdle(ctx: Context): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (agent, status) => { + if (agent.id === 'main' && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +function messageText(message: Message | undefined): string { + return message?.content.map(block => block.type === 'text' ? block.text : '').join('\n') ?? '' +} + describe('dsh-agent-spine-demo bundle', () => { it('brings up the full default spine', async () => { - const ctx = await mount() + const ctx = await mount({ workspaceContext: false }) // One service from each layer of the spine proves the children loaded. expect(ctx.get('timer')).toBeDefined() expect(ctx.get('llm')).toBeDefined() @@ -99,7 +117,7 @@ describe('dsh-agent-spine-demo bundle', () => { }) it('includes the skill registry, local provider, and skill tool without builtin skills', async () => { - const ctx = await mount() + const ctx = await mount({ workspaceContext: false }) expect(ctx.skills).toBeDefined() expect(ctx.tools.schemas().map(tool => tool.name)).toContain('skill') @@ -109,7 +127,7 @@ describe('dsh-agent-spine-demo bundle', () => { }) it('defaults the agents list to empty (no pre-created agents)', async () => { - const ctx = await mount() + const ctx = await mount({ workspaceContext: false }) expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -118,6 +136,7 @@ describe('dsh-agent-spine-demo bundle', () => { const ctx = await mount({ agents: [{ id: AgentId('main'), model: 'mock' }], persona: 'You are main.', + workspaceContext: false, }) expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() const assembly = await ctx.get('systemPrompt')!.assemble() @@ -129,7 +148,7 @@ describe('dsh-agent-spine-demo bundle', () => { // ctx.plugin validates + defaults the bundle config first; a direct apply // skips the schema, so the forwarding `?? []` / `?? ''` are what fire. const ctx = new Context() - agentCore.apply(ctx, {}) + agentCore.apply(ctx, { workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 50)) expect(ctx.get('agentLoop')).toBeDefined() expect(ctx.get('agents')?.list()).toHaveLength(0) @@ -138,6 +157,64 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('loads workspace instructions into requests through the bundled spine', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-workspace-context-')) + try { + await mkdir(join(root, '.git'), { recursive: true }) + await writeFile(join(root, 'AGENTS.md'), 'bundled project rule') + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await mount({ workspaceContext: { maxBytes: 65536 } }) + await ctx.plugin(LocalFileSystem, { cwd: '/' }) + ctx.llm.registerAdapter(['mock'], adapter) + const handle = await ctx.agents.create({ + agentId: AgentId('main'), + sessionId: SessionId('main-session'), + meta: { cwd: root }, + agentOptions: { model: 'mock' }, + }) + const agent = handle.agent + + agent.send([{ type: 'text', text: 'hi' }]) + await waitForMainIdle(ctx) + + const sentText = adapter.requests[0]?.messages.map(messageText).join('\n') + expect(sentText).toContain('hi') + expect(sentText).toContain('bundled project rule') + expect(adapter.requests[0]?.system).toContain('You are an AI agent powered by the DeepSeek Harness SDK.') + expect(adapter.requests[0]?.system).not.toContain('bundled project rule') + await handle.dispose() + await ctx.fiber.dispose() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('forwards workspace-context config to the bundled loader', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-workspace-context-disabled-')) + try { + await mkdir(join(root, '.git'), { recursive: true }) + await writeFile(join(root, 'AGENTS.md'), 'must not be injected') + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await mount({ workspaceContext: { maxBytes: 0 } }) + ctx.llm.registerAdapter(['mock'], adapter) + const handle = await ctx.agents.create({ + agentId: AgentId('main'), + sessionId: SessionId('main-disabled-session'), + meta: { cwd: root }, + agentOptions: { model: 'mock' }, + }) + + handle.agent.send([{ type: 'text', text: 'hi' }]) + await waitForMainIdle(ctx) + + expect(adapter.requests[0]?.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]) + await handle.dispose() + await ctx.fiber.dispose() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it('forwards skill config to the registry, local provider, and model-facing consumer', async () => { const home = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-home-')) const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-agents-')) @@ -146,6 +223,7 @@ describe('dsh-agent-spine-demo bundle', () => { await writeFile(join(custom, 'custom-skill.md'), '---\nname: custom-skill\ndescription: Custom skill\n---\n\nCustom body.\n') const ctx = await mount({ agents: [], + workspaceContext: false, skills: { registry: { collectCacheMaxEntries: 4 }, local: { @@ -161,8 +239,43 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('places workspace instructions before the skill catalog in the session prefix', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-prefix-order-')) + try { + await mkdir(join(root, '.git'), { recursive: true }) + await writeFile(join(root, 'AGENTS.md'), 'workspace rule before skills') + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await mount({ workspaceContext: { maxBytes: 65536 } }) + await ctx.plugin(LocalFileSystem, { cwd: '/' }) + ctx.llm.registerAdapter(['mock'], adapter) + ctx.skills.register({ + name: 'prefix-order-skill', + description: 'Skill catalog after workspace rules', + source: 'runtime', + content: 'body', + }) + const handle = await ctx.agents.create({ + agentId: AgentId('main'), + sessionId: SessionId('prefix-order-session'), + meta: { cwd: root }, + agentOptions: { model: 'mock' }, + }) + + handle.agent.send([{ type: 'text', text: 'hi' }]) + await waitForMainIdle(ctx) + + expect(messageText(adapter.requests[0]?.messages[0])).toContain('workspace rule before skills') + expect(messageText(adapter.requests[0]?.messages[1])).toContain('prefix-order-skill') + await handle.dispose() + await ctx.fiber.dispose() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it('forwards its bundled tool configs to tool-bash and tool-tasks', async () => { const ctx = await mount({ + workspaceContext: false, toolBash: { enableRunInBackground: false }, toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, }, true) @@ -188,10 +301,34 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('picks shared spine config without leaking front-door fields', () => { + const appConfig = { + model: 'front-door-only', + persona: 'You are merged.', + toolOrder: ['zulu'], + tools: { mode: 'native' as const }, + workspaceContext: false as const, + skills: {}, + toolBash: { enableRunInBackground: false }, + toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, + } + + expect(agentCore.pickSpineConfig(appConfig)).toEqual({ + persona: appConfig.persona, + toolOrder: appConfig.toolOrder, + tools: appConfig.tools, + workspaceContext: false, + skills: {}, + toolBash: appConfig.toolBash, + toolTasks: appConfig.toolTasks, + }) + expect(agentCore.pickSpineConfig({ workspaceContext: false })).toEqual({ workspaceContext: false }) + }) + it('uses the default skill config when apply is called directly without skills', async () => { await withIsolatedSkillHomes(async () => { const ctx = new Context() - agentCore.apply(ctx, { agents: [] }) + agentCore.apply(ctx, { agents: [], workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 50)) expect(ctx.skills).toBeDefined() expect(await ctx.skills.list()).toEqual([]) @@ -200,7 +337,7 @@ describe('dsh-agent-spine-demo bundle', () => { }) it('forwards toolOrder to the system-prompt assembly', async () => { - const ctx = await mount({ toolOrder: ['zulu', TOOL_ORDER_REST] }) + const ctx = await mount({ toolOrder: ['zulu', TOOL_ORDER_REST], workspaceContext: false }) // The bundle's own bash tools pend on the absent `ctx.bash` executor in // this providerless mount, so register two plain tools to order. for (const name of ['alpha', 'zulu']) { @@ -216,6 +353,16 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('supports direct apply with workspace instructions disabled and no forwarded agents', async () => { + const ctx = new Context() + agentCore.apply(ctx, { workspaceContext: false }) + await new Promise(resolve => setTimeout(resolve, 50)) + + expect(ctx.get('agents')?.list()).toEqual([]) + expect(ctx.get('systemPrompt')).toBeDefined() + await ctx.fiber.dispose() + }) + it('re-exports the loop config schema as its own', () => { expect(agentCore.Config).toBeDefined() expect(agentCore.name).toBe('agent-spine-demo') diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index faea4b949f..9a51ffa8c8 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -41,6 +41,9 @@ { "path": "../../core/agent" }, + { + "path": "../../context/workspace-context" + }, { "path": "../../core/agent-loop" }, diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index 98f639491e..d469acf6c4 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -19,7 +19,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha `@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. -The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-core`, `hmr`, and the two leaf backends. +The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-spine-demo`, `hmr`, and the two leaf backends. ## Config diff --git a/packages/examples/stdio-demo/package.json b/packages/examples/stdio-demo/package.json index 3fe11f47a2..a523f61213 100644 --- a/packages/examples/stdio-demo/package.json +++ b/packages/examples/stdio-demo/package.json @@ -37,6 +37,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", + "@deepseek-ai/dsh-workspace-context": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-stdio": "^0.0.1", @@ -55,6 +56,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-stdio": "workspace:^", diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index 73f163d7f4..d7ae866b53 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -16,6 +16,7 @@ import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' +import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' @@ -58,6 +59,8 @@ export interface Config { * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). */ resumeSessionId?: string + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] } export const Config: z = z.object({ @@ -76,6 +79,7 @@ export const Config: z = z.object({ toolBash: agentCore.ToolBashConfigSchema, toolTasks: agentCore.ToolTasksConfigSchema, resumeSessionId: z.string(), + workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) /** @@ -88,18 +92,13 @@ export const Config: z = z.object({ export function apply(ctx: Context, config: Config): void { ctx.plugin(ConsoleExporter) ctx.plugin(agentCore, { - ...config.persona !== undefined ? { persona: config.persona } : {}, - ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, - ...config.tools !== undefined ? { tools: config.tools } : {}, + ...agentCore.pickSpineConfig(config), agents: [{ id: AgentId('main'), model: config.model, cwd: process.cwd(), ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, }], - ...config.skills !== undefined ? { skills: config.skills } : {}, - ...config.toolBash !== undefined ? { toolBash: config.toolBash } : {}, - ...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {}, }) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) ctx.plugin(UserInteractionService) diff --git a/packages/examples/stdio-demo/tests/built-bin.e2e.ts b/packages/examples/stdio-demo/tests/built-bin.e2e.ts index ec14440b18..ad2ab5239d 100644 --- a/packages/examples/stdio-demo/tests/built-bin.e2e.ts +++ b/packages/examples/stdio-demo/tests/built-bin.e2e.ts @@ -21,9 +21,9 @@ const stdioBin = join(repoRoot, 'packages/examples/stdio-demo/lib/bin.js') const dshPackages = [ 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', - 'bash/tool-bash', 'support/invariants', 'ui/app-boot', + 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', - 'session-persistence/session-persistence-jsonl', 'examples/stdio-demo', + 'session-persistence/session-persistence-jsonl', 'examples/stdio-demo', 'util/paths', 'ui/stdio', 'ui/tool-ask-user', 'ui/user-interaction', ] const vendorPackages = [ @@ -75,6 +75,7 @@ async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promi ' config:', ' model: mock-echo', ' persona: \'demo\'', + ' workspaceContext: false', ` welcome: '${welcome}'`, ...disabledBrokenEntry ? ['- id: off', ' name: \'./src/does-not-exist.ts\'', ' disabled: true'] diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts index b5fb0e0207..1dd9fda575 100644 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts @@ -11,7 +11,7 @@ import * as stdioAgent from '../src/index.ts' /** * Unit coverage for app composition and config forwarding: console logger, pre-created main agent, - * agent-core spine, JSONL backend, and readline UI. HMR is a Loader-only leaf concern covered by the + * agent-spine-demo spine, JSONL backend, and readline UI. HMR is a Loader-only leaf concern covered by the * keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise * survive namespace collapse while silently losing its schema. */ @@ -66,8 +66,8 @@ async function withIsolatedSkillHomes(run: () => Promise): Promise { describe('dsh-stdio-demo app', () => { it('composes the spine + front-door cluster and pre-creates the main agent', async () => { - const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig() }) - // The spine services (brought up by the agent-core bundle) are all present. + const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig(), workspaceContext: false }) + // The spine services (brought up by the agent-spine-demo bundle) are all present. expect(ctx.get('agents')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined() expect(ctx.get('sessionPersistence')).toBeDefined() @@ -87,17 +87,28 @@ describe('dsh-stdio-demo app', () => { // schema-bypassing direct-mount caller. const ctx = new Context() // No persona: covers the omitted-persona forwarding branch too. - stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() }) + stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 80)) expect(ctx.get('sessionPersistence')).toBeDefined() expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() await ctx.fiber.dispose() }) + it('forwards explicit project-instruction controls to the bundled spine', async () => { + const ctx = await mount({ + model: 'mock', + persona: 'hi', + persistenceRoot: '/tmp/dsh-stdio-demo-spec-workspace-context', + workspaceContext: false, + }) + expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + await ctx.fiber.dispose() + }) + it('uses default skill config when apply is called directly without skills', async () => { await withIsolatedSkillHomes(async () => { const ctx = new Context() - stdioAgent.apply(ctx, { model: 'mock' }) + stdioAgent.apply(ctx, { model: 'mock', workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 80)) expect(ctx.skills).toBeDefined() expect(await ctx.skills.list()).toEqual([]) @@ -115,13 +126,14 @@ describe('dsh-stdio-demo app', () => { persistenceRoot: '/tmp/dsh-stdio-demo-spec-resume', resumeSessionId: 'no-such-session', skills: await isolatedSkillsConfig(), + workspaceContext: false, }) expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() await ctx.fiber.dispose() }) - it('forwards skill config into agent-core', async () => { - const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) }) + it('forwards skill config into agent-spine-demo', async () => { + const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6), workspaceContext: false }) ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' }) expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...') await ctx.fiber.dispose() @@ -130,6 +142,7 @@ describe('dsh-stdio-demo app', () => { it('forwards bundled tool config into agent-core', async () => { const ctx = await mount({ model: 'mock', + workspaceContext: false, toolBash: { enableRunInBackground: false }, toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, skills: await isolatedSkillsConfig(), @@ -145,11 +158,12 @@ describe('dsh-stdio-demo app', () => { expect(stdioAgent.Config).toBeDefined() }) - it('forwards toolOrder through agent-core to the system-prompt assembly', async () => { + it('forwards toolOrder through agent-spine-demo to the system-prompt assembly', async () => { const ctx = await mount({ model: 'mock', toolOrder: ['zulu', TOOL_ORDER_REST], persistenceRoot: '/tmp/dsh-stdio-demo-spec-tool-order', + workspaceContext: false, }) // The bundle's own bash tools pend on the absent `ctx.bash` executor in // this providerless mount, so register two plain tools to order. diff --git a/packages/examples/stdio-demo/tsconfig.json b/packages/examples/stdio-demo/tsconfig.json index bb360810a5..be08d28f95 100644 --- a/packages/examples/stdio-demo/tsconfig.json +++ b/packages/examples/stdio-demo/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../agent-spine-demo" }, + { + "path": "../../context/workspace-context" + }, { "path": "../../ui/user-interaction" }, diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index e950385223..65ed76efce 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-fs-local -The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the seven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`. +The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the eight `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`. ```ts ignore-check import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' @@ -12,8 +12,8 @@ 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. -- **`stat`** — returns `FsInfo` (`version` = `mtimeMs:size`, `type` of `file`/`directory`/`other`, byte `size`) or `undefined` when the target is absent. +- **`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` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence. - **`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`. - **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index fba2a240eb..360145e8c8 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -7,8 +7,8 @@ import { randomUUID } from 'node:crypto' import { createReadStream } from 'node:fs' -import { chmod, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises' -import type { Dirent, Stats } from 'node:fs' +import { chmod, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises' +import type { BigIntStats, Dirent, Stats } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' import { TextDecoder } from 'node:util' import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' @@ -63,9 +63,9 @@ async function readFileAbortable(absolutePath: string, verb: 'read' | 'edit', si } } -/** Opaque version token from a stat: mtime (ns precision) + size. */ -function versionOf(info: Stats): FsVersion { - return FsVersion(`${info.mtimeMs}:${info.size}`) +/** Opaque version token from high-resolution identity and freshness metadata. */ +function versionOf(info: BigIntStats): FsVersion { + return FsVersion(`${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`) } /** @@ -98,6 +98,14 @@ export interface PathInfo { size: number } +/** Result of probing a path without following the final symlink component. */ +export interface PathLinkInfo { + version: FsVersion + mode: number + type: 'file' | 'directory' | 'symlink' | 'other' + size: number +} + /** One local directory child with a resolved target and cheap metadata. */ export interface LocalDirEntry { name: string @@ -150,22 +158,62 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise( + absolutePath: string, + readStats: (path: string) => Promise, +): Promise { + try { + return await readStats(absolutePath) + } catch (error: unknown) { + // ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean + // the target is absent; any other metadata failure is a real permission/IO + // fault. + /* v8 ignore next -- a non-ENOENT/ENOTDIR metadata failure needs a permission/IO fault; surface it. */ + if (!isENOENT(error) && !isENOTDIR(error)) throw error + return null + } +} + /** * Probe a path for its version, mode, type, and size. Null if absent. * @param absolutePath - the path to stat (typically a target key; symlinks are followed). * @returns the metadata, or null when the path — or a parent segment — does not exist. */ export async function probe(absolutePath: string): Promise { - try { - const info = await stat(absolutePath) - const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other' - return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size } - } catch (error: unknown) { - // ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean - // the target is absent; any other stat failure is a real permission/IO fault. - /* v8 ignore next -- a non-ENOENT/ENOTDIR stat failure needs a permission/IO fault; surface it. */ - if (!isENOENT(error) && !isENOTDIR(error)) throw error - return null + const info = await probeStats(absolutePath, path => stat(path, { bigint: true })) + if (!info) return null + return { + version: versionOf(info), + mode: Number(info.mode & 0o777n), + type: pathType(info), + size: Number(info.size), + } +} + +/** + * Probe a path without following the final symlink component. + * @param absolutePath - the path entry to inspect with `lstat` semantics. + * @returns path-entry metadata, or null when the entry is absent. + */ +export async function probeNoFollow(absolutePath: string): Promise { + const info = await probeStats(absolutePath, path => lstat(path, { bigint: true })) + if (!info) return null + return { + version: versionOf(info), + mode: Number(info.mode & 0o777n), + type: pathLinkType(info), + size: Number(info.size), } } diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 43ca988338..bd47b53919 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -5,6 +5,7 @@ */ import { Context } from 'cordis' +import { resolve } from 'node:path' import z from 'schemastery' import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs' import type { @@ -12,6 +13,7 @@ import type { FsEditOutcome, FsEditRequest, FsInfo, + FsPathInfo, FsTarget, FsWriteIntent, FsWriteOutcome, @@ -21,6 +23,7 @@ import { listDirectory, normalizeLineEndings, probe, + probeNoFollow, readForEdit, readTextForDiff, readWholeText, @@ -80,14 +83,26 @@ 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 } } override async stat(target: FsTarget, signal?: AbortSignal): Promise { if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED') const info = await probe(target.targetKey) + if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED') + if (!info) return undefined + return { version: info.version, type: info.type, size: info.size } + } + + override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise { + if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED') + if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND') + const info = await probeNoFollow(resolve(opts?.cwd ?? this.config.cwd, path)) + if (signal?.aborted) throw new FsError('lstat aborted', 'FS_ABORTED') if (!info) return undefined return { version: info.version, type: info.type, size: info.size } } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 997021d346..61e2c0e999 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -6,8 +6,8 @@ * `dsh-fs-policy`, so it is not exercised here. */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, unlink, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' @@ -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', () => { @@ -87,11 +99,104 @@ describe('stat', () => { expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined() }) + it('changes version after a same-size rewrite even when mtime is restored', async () => { + const path = join(dir, 'same-size.txt') + await writeFile(path, 'first') + const target = await fs.resolve(path) + const beforeInfo = await stat(path) + const beforeVersion = await versionOf(target) + + await fs.writeText(target, 'other') + await utimes(path, beforeInfo.atime, beforeInfo.mtime) + + expect((await stat(path)).size).toBe(beforeInfo.size) + expect(await versionOf(target)).not.toBe(beforeVersion) + }) + it('honors a pre-aborted signal', async () => { await expect(fs.stat(await fs.resolve('a.txt'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) }) }) +describe('lstat', () => { + it('reports path metadata without following the final symlink component', async () => { + await writeFile(join(dir, 'real.txt'), 'hello') + await symlink(join(dir, 'real.txt'), join(dir, 'link.txt')) + + expect((await fs.lstat('real.txt'))?.type).toBe('file') + expect((await fs.lstat('link.txt'))?.type).toBe('symlink') + expect(await fs.lstat('missing.txt')).toBeUndefined() + }) + + it('resolves relative paths against opts.cwd and honors a pre-aborted signal', async () => { + const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-')) + try { + await writeFile(join(other, 'x.txt'), 'in other') + expect((await fs.lstat('x.txt', { cwd: other }))?.type).toBe('file') + await expect(fs.lstat('x.txt', { cwd: other }, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + await expect(fs.lstat(' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + } finally { + await rm(other, { recursive: true, force: true }) + } + }) +}) + +describe('metadata cancellation', () => { + it('rejects stat and lstat when their signals abort while the metadata probes are in flight', async () => { + await writeFile(join(dir, 'slow.txt'), 'hello') + const statStarted = Promise.withResolvers() + const statRelease = Promise.withResolvers() + const lstatStarted = Promise.withResolvers() + const lstatRelease = Promise.withResolvers() + let isolatedCtx: Context | undefined + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async stat(path: string) { + statStarted.resolve(undefined) + await statRelease.promise + return actual.stat(path, { bigint: true }) + }, + async lstat(path: string) { + lstatStarted.resolve(undefined) + await lstatRelease.promise + return actual.lstat(path, { bigint: true }) + }, + } + }) + + try { + const { LocalFileSystem: IsolatedLocalFileSystem } = await import('../src/index.ts') + isolatedCtx = new Context() + await isolatedCtx.plugin(IsolatedLocalFileSystem, { cwd: dir }) + const isolatedFs = isolatedCtx.fs as InstanceType + const target = await isolatedFs.resolve('slow.txt') + const statController = new AbortController() + const lstatController = new AbortController() + const pendingStat = isolatedFs.stat(target, statController.signal) + const pendingLstat = isolatedFs.lstat('slow.txt', undefined, lstatController.signal) + + await Promise.all([statStarted.promise, lstatStarted.promise]) + statController.abort() + lstatController.abort() + const statRejected = expect(pendingStat).rejects.toMatchObject({ code: 'FS_ABORTED' }) + const lstatRejected = expect(pendingLstat).rejects.toMatchObject({ code: 'FS_ABORTED' }) + statRelease.resolve(undefined) + lstatRelease.resolve(undefined) + + await Promise.all([statRejected, lstatRejected]) + } finally { + statRelease.resolve(undefined) + lstatRelease.resolve(undefined) + await isolatedCtx?.fiber.dispose() + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) +}) + describe('readText / streamText', () => { it('reads whole-file text', async () => { await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree') @@ -292,9 +397,6 @@ describe('writeText', () => { await writeFile(join(dir, 'a.txt'), 'v1') const target = await fs.resolve('a.txt') const before = await versionOf(target) - // Change the byte length so the mtimeMs:size token provably differs (a - // same-size same-tick rewrite can collide — the documented version-token - // limitation; not what this test is about). const outcome = await fs.writeText(target, 'a much longer replacement body', { kind: 'replaceIfVersion', version: before }) expect(outcome.version).not.toBe(before) expect(outcome.version).toBe(await versionOf(target)) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 6723ae9d9c..199c01f411 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -14,6 +14,7 @@ import { applyLiteralEdit, listDirectory, probe, + probeNoFollow, readForEdit, readWholeText, resolveLocalTarget, @@ -146,6 +147,27 @@ describe('probe', () => { }) }) +describe('probeNoFollow', () => { + it('reports symlinks without following them', async () => { + const real = join(dir, 'real.txt') + const link = join(dir, 'link.txt') + await writeFile(real, 'hi') + await symlink(real, link) + + expect((await probeNoFollow(real))?.type).toBe('file') + const linkInfo = await probeNoFollow(link) + expect(linkInfo?.type).toBe('symlink') + expect(typeof linkInfo?.version).toBe('string') + expect(linkInfo?.size).toBeGreaterThan(0) + }) + + it('returns null for a missing path or a file-valued ancestor path segment', async () => { + expect(await probeNoFollow(join(dir, 'missing'))).toBeNull() + await writeFile(join(dir, 'afile'), 'i am a file') + expect(await probeNoFollow(join(dir, 'afile', 'child.txt'))).toBeNull() + }) +}) + describe('listDirectory', () => { it('lists direct children in stable order without reading content', async () => { const root = join(dir, 'skills') diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index bad4133b72..6dae32d235 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-fs -The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. +The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, no-follow path metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate RFC](../../../docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md)): @@ -15,12 +15,13 @@ A future sandboxed, virtual, or remote backend implements this interface and the ## Service API (`ctx.fs`) -A backend subclasses `FileSystem` and implements seven primitives. +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`). | | `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). | | `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. | @@ -41,7 +42,7 @@ This package declares three events (see the generated [events catalog](../../../ ## Vocabulary -`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. +`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. ## Model Experience @@ -50,6 +51,6 @@ Indirectly, through `dsh-tool-fs`, which renders provider text and errors as bou ## Known Limitations and Deferred Work - **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md). -- **Seven primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing RFC](../../../docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md). +- **Eight primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing RFC](../../../docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md). - **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)). - **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend. diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 1c70226aff..8466962f5a 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -12,6 +12,7 @@ import type { FsEditOutcome, FsEditRequest, FsInfo, + FsPathInfo, FsTarget, FsVersion, FsWriteIntent, @@ -29,6 +30,7 @@ export type { FsDirEntry, FsErrorCode, FsInfo, + FsPathInfo, FsTarget, FsWriteIntent, FsWriteOutcome, @@ -86,10 +88,10 @@ export abstract class FileSystem extends Service { * async even though the local backend only normalizes + realpaths. * * @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. @@ -99,6 +101,22 @@ export abstract class FileSystem extends Service { */ abstract stat(target: FsTarget, signal?: AbortSignal): Promise + /** + * Return path metadata without following the final path component when it is a + * symbolic link. This is intentionally path-shaped, not target-shaped: + * {@link resolve} follows symlinks to produce the stable identity used by + * normal reads/writes, while `lstat` lets a consumer reject the path itself + * before that follow happens. + * + * `opts.cwd` follows {@link resolve}'s cwd rules. `undefined` means the path is + * absent. + * @param path - the path to inspect; relative paths resolve against `opts.cwd`. + * @param opts - `cwd` overrides the backend's default base for relative paths. + * @param signal - aborts the metadata round-trip. + * @returns metadata only, never content; undefined for an absent path. + */ + abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise + /** * Read the whole regular text file as a single decoded string. * @param target - the resolved target to read. diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 73c8ff4837..c76e88da4b 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -27,16 +27,17 @@ export function FsTargetKey(key: string): FsTargetKey { /** * Opaque file-version token — the freshness token a write/edit guards against. - * The local backend derives it from mtime+size; a remote backend might use a - * revision id. The policy layer records it for stale checks; consumers may - * display related metadata but MUST NOT interpret this token. + * The local backend derives it from high-resolution stat identity and freshness + * fields; a remote backend might use a revision id. The policy layer records it + * for stale checks; consumers may display related metadata but MUST NOT + * interpret this token. */ export type FsVersion = Branded<'FsVersion'> /** * Brand a string as an {@link FsVersion}. For backend use only — a consumer * never manufactures a version, it receives one from `stat`/write/edit outcomes. - * @param v - the backend's raw version string (the local backend derives it from mtime+size). + * @param v - the backend's raw version string. * @returns the same string, branded; no validation is performed. */ export function FsVersion(v: string): FsVersion { @@ -72,6 +73,21 @@ export interface FsInfo { size?: number } +/** + * Metadata about a path without following the final path component when it is a + * symbolic link. Unlike {@link FsInfo}, this path-level probe can report + * `symlink` so consumers with trust-boundary rules can reject repository-owned + * links before resolving a target. + */ +export interface FsPathInfo { + /** Opaque freshness token of the path entry right now. */ + version: FsVersion + /** Whether the path entry is a regular file, directory, symlink, or other. */ + type: 'file' | 'directory' | 'symlink' | 'other' + /** Byte size of the path entry, when the backend can report it. */ + size?: number +} + /** * One direct child returned by {@link FileSystem.listDir}. Listing returns * metadata and resolved targets only; it must not read file contents. diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 86ba782c96..19ee033cce 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -13,12 +13,13 @@ import type { FsEditOutcome, FsEditRequest, FsInfo, + FsPathInfo, FsTarget, FsWriteIntent, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' -/** A minimal in-memory fake implementing the seven provider primitives. */ +/** A minimal in-memory fake implementing the eight provider primitives. */ class FakeFileSystem extends FileSystem { files = new Map() @@ -30,6 +31,11 @@ class FakeFileSystem extends FileSystem { if (content === undefined) return undefined return { version: FsVersion('v1'), type: 'file', size: content.length } } + override async lstat(path: string): Promise { + const content = this.files.get(path) + if (content === undefined) return undefined + return { version: FsVersion('v1'), type: 'file', size: content.length } + } override async readText(target: FsTarget): Promise { const content = this.files.get(target.targetKey) if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND') @@ -120,6 +126,15 @@ describe('FileSystem provider seam', () => { const fs = ctx.fs as FakeFileSystem expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined() }) + + it('lstat returns path metadata before resolving a target', async () => { + const ctx = new Context() + await ctx.plugin(FakeFileSystem) + const fs = ctx.fs as FakeFileSystem + fs.files.set('a.txt', 'hi') + expect(await fs.lstat('a.txt')).toEqual({ version: 'v1', type: 'file', size: 2 }) + expect(await fs.lstat('missing.txt')).toBeUndefined() + }) }) describe('branded id factories', () => { diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index bf3633a253..5b0d439238 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 c347d7d958..bc6f7a8fb8 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -12,7 +12,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts' -import { sessionCwd } from './session-cwd.ts' +import { sessionResolveOptions } from './session-cwd.ts' /** Validated `edit` arguments after defaulting. */ interface EditInput { @@ -75,8 +75,7 @@ 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, sessionResolveOptions(exec)) // 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 77bb76eb20..63ba070126 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -13,7 +13,7 @@ import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' import { buildWindow, formatReadOutput } from './read-render.ts' import type { FileReadOutcome } from './read-render.ts' -import { sessionCwd } from './session-cwd.ts' +import { sessionResolveOptions } from './session-cwd.ts' /** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */ export const READ_LIMIT = 2000 @@ -86,8 +86,7 @@ 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, sessionResolveOptions(exec)) // One stat: type check + size routing + the version recorded as observed. // A concurrent write can only make a later guarded mutation fail stale and require reread. diff --git a/packages/fs/tool-fs/src/session-cwd.ts b/packages/fs/tool-fs/src/session-cwd.ts index 2f53d630ce..4f98a41a94 100644 --- a/packages/fs/tool-fs/src/session-cwd.ts +++ b/packages/fs/tool-fs/src/session-cwd.ts @@ -18,3 +18,16 @@ import type { ToolExecution } from '@deepseek-ai/dsh-tools' export function sessionCwd(exec: ToolExecution): string | undefined { return exec.agent?.session.header.cwd } + +/** + * Resolution options shared by all model-facing filesystem tools. + * @param exec - the tool-execution context supplying session cwd and cancellation. + * @returns provider resolution options for the current tool call. + */ +export function sessionResolveOptions(exec: ToolExecution): { cwd?: string; signal?: AbortSignal } { + const cwd = sessionCwd(exec) + return { + ...cwd !== undefined ? { cwd } : {}, + ...exec.signal !== undefined ? { signal: exec.signal } : {}, + } +} diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 86fe186e3b..8ab3ce0f62 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -13,7 +13,7 @@ import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts' -import { sessionCwd } from './session-cwd.ts' +import { sessionResolveOptions } from './session-cwd.ts' /** * Validate value constraints the schema DSL can't express: only a non-blank @@ -61,8 +61,7 @@ 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, sessionResolveOptions(exec)) // 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/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index e4367b8702..27c2d5f672 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -14,6 +14,7 @@ import type { FsEditOutcome, FsEditRequest, FsInfo, + FsPathInfo, FsTarget, FsWriteIntent, FsWriteOutcome, @@ -44,6 +45,11 @@ class FakeFs extends FileSystem { if (content === undefined) return undefined return { version: FsVersion('v1'), type: 'file', size: content.length } } + override async lstat(path: string): Promise { + const content = this.files.get(`key:${path}`) + if (content === undefined) return undefined + return { version: FsVersion('v1'), type: 'file', size: content.length } + } override async readText(target: FsTarget): Promise { return this.files.get(target.targetKey) ?? '' } diff --git a/packages/guard/README.md b/packages/guard/README.md index 9198698b20..05c9625cb0 100644 --- a/packages/guard/README.md +++ b/packages/guard/README.md @@ -6,4 +6,4 @@ Behavioral guard plugins that watch the agent loop for unproductive patterns and |---|---|---| | `repeat-tool-guard/` | Advisory reminders when an agent loops on identical tool calls | (listens on `ctx.tools`' waterfalls) | -Reminders travel as `additionalContext` on the `tools/post-execute` decision; the agent loop appends them as logged `context/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log. +Reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged `context/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log. diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index 843611c2a7..5a9fed247f 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -30,7 +30,7 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de ## Reminder delivery -Reminders use source-attributed `additionalContext`, preserving the tool's original result. The loop records them after the step's results as reconstructable `context/message` events. The guard always delegates and folds its reminder onto downstream context, including blocked calls. +Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as a `context/message` after the step's tool results, which the session renders as the tagged synthetic-user envelope — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source, envelope, and metadata. ## Testing diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index ca4e6b5c0e..9df8dd399f 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -140,16 +140,11 @@ function validateThresholds(values: number[]): number[] { } /** - * Concatenate the guard's reminder context with a downstream listener's - * optional one so folding drops neither. The merged block carries the guard's - * `source` — a `HookContext` holds one `MessageSource` and the seam cannot - * represent mixed provenance; the rendered `context/message` only - * distinguishes by `source.kind`, so a downstream plugin's text is still - * correctly framed as plugin context. + * Prepend the guard's reminder while preserving every downstream context's + * source, envelope, and metadata. */ -function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext { - if (!theirs) return ours - return { content: [...ours.content, ...theirs.content], source: ours.source } +function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] { + return [ours, ...theirs ?? []] } /** One agent's consecutive-repeat chain: the last tracked call's identity key and its run length. */ @@ -211,19 +206,19 @@ export function apply(ctx: Context, config: Config): void { // Observe-and-enrich, never veto: count first (state advances regardless of // the downstream outcome), DELEGATE so a later listener can still block or - // replace, then fold the reminder onto whatever came back — additionalContext + // replace, then fold the reminder onto whatever came back — additionalContexts // rides both decision variants, so a blocked call still gets the nudge. ctx.on('tools/post-execute', async (exec, _result, next): Promise => { const reminder = observe(exec) const downstream = await next() if (!reminder) return downstream if (downstream.kind === 'block') { - return { kind: 'block', feedback: downstream.feedback, additionalContext: concatContext(reminder, downstream.additionalContext) } + return { kind: 'block', feedback: downstream.feedback, additionalContexts: prependContext(reminder, downstream.additionalContexts) } } return { kind: 'accept', ...downstream.content !== undefined ? { content: downstream.content } : {}, - additionalContext: concatContext(reminder, downstream.additionalContext), + additionalContexts: prependContext(reminder, downstream.additionalContexts), } }) diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 565f1076b5..5be98256b0 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -309,7 +309,7 @@ describe('fold onto the downstream decision', () => { ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'nope' }], - additionalContext: { content: [{ type: 'text' as const, text: 'downstream-ctx' }], source: { kind: 'plugin' as const, plugin: 'test' } }, + additionalContexts: [{ content: [{ type: 'text' as const, text: 'downstream-ctx' }], source: { kind: 'plugin' as const, plugin: 'test' } }], })) const adapter = new MockAdapter([ toolCallResponse('c1', 'probe', { q: 1 }), @@ -322,14 +322,14 @@ describe('fold onto the downstream decision', () => { await waitForIdle(ctx, agent) const found = reminders(agent) - expect(found).toHaveLength(2) + expect(found).toHaveLength(3) // Call 1: below threshold — the downstream context passes through untouched. expect(found[0]!.text).toBe('downstream-ctx') expect(found[0]!.source).toEqual({ kind: 'plugin', plugin: 'test' }) - // Call 2: reminder folded in front, single merged context, the guard's source. + // Call 2: reminder and downstream context retain separate provenance. expect(found[1]!.text).toContain('repeating the exact same tool call') - expect(found[1]!.text).toContain('|downstream-ctx') expect(found[1]!.source).toEqual(GUARD_SOURCE) + expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } }) // The block's feedback reached the tool result unchanged. const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') expect(results.every(r => r.data.isError)).toBe(true) diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index efd5df183c..700acadda8 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -35,9 +35,9 @@ 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 fold context onto the downstream decision (a Code Mode sub-call’s context is dropped by the run_code bridge — see [the pipeline doc](../../../docs/tool-execution-pipeline.md)) | +| `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 | | `SubagentStart` | `subagent/start` (emit) | additionalContext → `agent.inject()` into a live in-process child; a remote child has no local injection target | | `SubagentStop` | `subagent/end` (emit) | observe-only | diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 08a2d26c9d..25f959260c 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -188,10 +188,9 @@ export function apply(ctx: Context, config: Config): void { return { content, source: PLUGIN_SOURCE } } - /** Merge hook context while retaining this bridge's plugin-level source. */ - function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext { - if (!theirs) return ours - return { content: [...ours.content, ...theirs.content], source: ours.source } + /** Prepend one context without flattening downstream provenance or metadata. */ + function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] { + return [ours, ...theirs ?? []] } // SessionStart injects context when its detached hook resolves; a slow hook @@ -224,7 +223,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), } }) @@ -243,19 +242,19 @@ export function apply(ctx: Context, config: Config): void { const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) const context = contextFrom(merged) if (merged.decision === 'deny') { - return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContext: context } : {} } + return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} } } // Our hooks did not block. DELEGATE so a later listener can still block/replace, // then fold our context onto its decision (a downstream block carries it too). const downstream = await next() if (!context) return downstream if (downstream.kind === 'block') { - return { ...downstream, additionalContext: concatContext(context, downstream.additionalContext) } + return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) } } return { kind: 'accept', ...downstream.content !== undefined ? { content: downstream.content } : {}, - additionalContext: concatContext(context, downstream.additionalContext), + additionalContexts: prependContext(context, downstream.additionalContexts), } }) diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index f376708688..0b251ece83 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -475,9 +475,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 }] }] }) @@ -486,7 +486,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' }]) @@ -498,6 +503,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 () => { @@ -518,6 +530,35 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) }) + it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => { + const d = dir() + const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') + const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.on('tools/post-execute', async () => ({ + kind: 'accept' as const, + additionalContexts: [{ + content: [{ type: 'text' as const, text: 'downstream-note' }], + 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) + + 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 listener BLOCK', async () => { // The bridge hook only adds context; a later post-execute listener blocks the // result. The block wins AND carries the bridge context (concatContext on the diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index 06cafe1297..078bbc2e9d 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -41,9 +41,9 @@ 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 fold context onto the downstream decision (a Code Mode sub-call’s context is dropped by the run_code bridge — see [the pipeline doc](../../../docs/tool-execution-pipeline.md)) | +| `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 | A tool call's payload carries the real `tool_name` (the same value the matcher tests) and Codex's `tool_input: { command }` shape (the `command` arg when present, else `''`). The matcher subject is the tool name (`PreToolUse`/`PostToolUse`) or the session source (`SessionStart`); `UserPromptSubmit`/`Stop` ignore matchers. diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 924b181151..ad240e2e4d 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -163,10 +163,9 @@ export function apply(ctx: Context, config: Config): void { return { content, source: PLUGIN_SOURCE } } - /** Merge hook context while retaining this bridge's plugin-level source. */ - function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext { - if (!theirs) return ours - return { content: [...ours.content, ...theirs.content], source: ours.source } + /** Prepend one context without flattening downstream provenance or metadata. */ + function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] { + return [ours, ...theirs ?? []] } // SessionStart injects plain stdout when its detached hook resolves; a slow @@ -196,7 +195,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), } }) @@ -216,19 +215,19 @@ export function apply(ctx: Context, config: Config): void { const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) const context = contextFrom(merged) if (merged.decision === 'deny') { - return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContext: context } : {} } + return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} } } // Context alone is not a veto: DELEGATE, then fold our context onto the // downstream decision (a downstream block carries it too). const downstream = await next() if (!context) return downstream if (downstream.kind === 'block') { - return { ...downstream, additionalContext: concatContext(context, downstream.additionalContext) } + return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) } } return { kind: 'accept', ...downstream.content !== undefined ? { content: downstream.content } : {}, - additionalContext: concatContext(context, downstream.additionalContext), + additionalContexts: prependContext(context, downstream.additionalContexts), } }) diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index c287d86b23..71bf4b2d39 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -85,7 +85,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')]) @@ -93,7 +93,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) @@ -101,6 +106,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 () => { @@ -117,6 +129,33 @@ describe('hooks-codex coverage — decision mapping paths', () => { expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) }) + it('keeps bridge and downstream PostToolUse contexts as separate sourced events', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + ctx.on('tools/post-execute', async () => ({ + kind: 'accept' as const, + additionalContexts: [{ + content: [{ type: 'text' as const, text: 'downstream-note' }], + 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) + + 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 listener BLOCK', async () => { const d = dir() hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n') }] }] }) @@ -394,7 +433,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const { CallId } = await import('@deepseek-ai/dsh-llm') const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) expect(result.isError).toBeFalsy() - expect(result.additionalContext?.content.some(b => b.type === 'text' && b.text === 'x')).toBe(true) + expect(result.additionalContexts?.[0]?.content.some(b => b.type === 'text' && b.text === 'x')).toBe(true) }) it('when the bash executor REJECTS, the hook/result omits exitCode (non-blocking)', async () => { diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index 94a48ce53f..43cc70c7ad 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import SkillService from '@deepseek-ai/dsh-skill' -import { FileSystem, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs' +import { FileSystem, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs' import * as SkillLocal from '../src/index.ts' async function tempDir(name: string): Promise { @@ -53,6 +53,20 @@ class TestFileSystem extends FileSystem { } } + override async lstat(path: string): Promise { + try { + const fs = await import('node:fs/promises') + const info = await fs.lstat(path) + return { + version: FsVersion(String(info.mtimeMs)), + type: info.isSymbolicLink() ? 'symlink' : info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other', + size: info.size, + } + } catch { + return undefined + } + } + override async readText(target: FsTarget, signal?: AbortSignal): Promise { this.readTextSignals.push(signal) if (this.readTextOverride !== undefined) return await this.readTextOverride(target, signal) diff --git a/packages/ui/jsonrpc/tests/plugin-apply.spec.ts b/packages/ui/jsonrpc/tests/plugin-apply.spec.ts index 3b4e585ff6..b65907f4c0 100644 --- a/packages/ui/jsonrpc/tests/plugin-apply.spec.ts +++ b/packages/ui/jsonrpc/tests/plugin-apply.spec.ts @@ -59,7 +59,7 @@ async function mountPlugin( options: { writeDelayMs?: number; failFlush?: boolean } = {}, ): Promise { const ctx = new Context() - await ctx.plugin(agentCore) + await ctx.plugin(agentCore, { workspaceContext: false }) await ctx.plugin(SessionPersistenceJsonl, { root: storageDir }) await new Promise(resolve => setTimeout(resolve, 50)) diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 8c024c6e81..c993bd4e2c 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -58,7 +58,7 @@ async function mockCompletionServer(): Promise<{ url: string; requests: unknown[ async function makeHarness(storageDir: string) { const ctx = new Context() - await ctx.plugin(agentCore) + await ctx.plugin(agentCore, { workspaceContext: false }) await ctx.plugin(SubagentService) await ctx.plugin(SessionPersistenceJsonl, { root: storageDir }) await new Promise(resolve => setTimeout(resolve, 50)) diff --git a/packages/util/README.md b/packages/util/README.md index 1296eb5935..57ee38fa1a 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -5,6 +5,7 @@ Zero-dependency primitives shared across the other groups. A package lands here | Package | Role | |---|---| | `brand/` | The type-only `Branded` nominal-typing primitive (no runtime code, no harness deps) | +| `paths/` | Shared filesystem path constants and helpers for harness user data | | `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability | `dsh-brand` is the canonical case: it owns ONLY the `Branded` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`. diff --git a/packages/util/paths/README.md b/packages/util/paths/README.md new file mode 100644 index 0000000000..2668e679b6 --- /dev/null +++ b/packages/util/paths/README.md @@ -0,0 +1,18 @@ +# dsh-paths + +Shared filesystem path helpers for DeepSeek Harness user data. + +## DSH home + +`DSH_HOME_DIR_NAME` owns the default user-data directory name: `.dsh`. + +`defaultDshHome()` returns the default DeepSeek Harness home by joining the operating-system home directory with `.dsh`, using Node's platform path rules. + +`expandHomePath()` expands `~`, `~/...`, and Windows-style `~\...` prefixes against the operating-system home directory. It leaves non-tilde paths and `~user/...` untouched. + +This package is intentionally small and harness-dep-free so product packages can share user-data path conventions without depending on one another. + +## Known Limitations and Deferred Work + +- **Expansion is deliberately narrow** — only bare `~`, `~/...`, and `~\...` use the current operating-system home; named-user forms such as `~alice/...`, environment variables, and shell expressions remain unchanged. +- **Helpers do not touch the filesystem** — callers still own directory creation, existence checks, permissions, and trust policy for the resulting path. diff --git a/packages/util/paths/package.json b/packages/util/paths/package.json new file mode 100644 index 0000000000..b4f760afe9 --- /dev/null +++ b/packages/util/paths/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-paths", + "description": "Shared filesystem path helpers for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/util/paths/src/index.ts b/packages/util/paths/src/index.ts new file mode 100644 index 0000000000..89e188cedd --- /dev/null +++ b/packages/util/paths/src/index.ts @@ -0,0 +1,47 @@ +/** + * Shared filesystem path helpers for DeepSeek Harness user data. + * + * @module @deepseek-ai/dsh-paths + */ + +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' + +/** Directory name for the default DeepSeek Harness home under the OS home. */ +export const DSH_HOME_DIR_NAME = '.dsh' + +/** Stable user-facing display form for the default DeepSeek Harness home. */ +export const DEFAULT_DSH_HOME_DISPLAY = `~/${DSH_HOME_DIR_NAME}` + +/** Environment variable that overrides the default DeepSeek Harness home. */ +export const DSH_HOME_ENV = 'DSH_HOME' + +/** + * Resolve the default DeepSeek Harness home using Node's platform path rules. + * @returns the absolute default harness home path. + */ +export function defaultDshHome(): string { + return join(homedir(), DSH_HOME_DIR_NAME) +} + +/** + * Expand supported tilde prefixes against the operating-system home. + * @param path - configured path that may begin with `~`, `~/`, or `~\`. + * @returns the expanded path, or the original value when no supported prefix is present. + */ +export function expandHomePath(path: string): string { + if (path === '~') return homedir() + if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2)) + return path +} + +/** + * Resolve an explicitly configured, environment-selected, or default DSH home. + * @param configured - explicit harness-home override, which has highest precedence. + * @param env - environment mapping used to read `DSH_HOME`. + * @returns the normalized absolute harness home path. + */ +export function resolveDshHome(configured?: string, env: Record = process.env): string { + const selected = configured ?? env[DSH_HOME_ENV] ?? defaultDshHome() + return resolve(expandHomePath(selected)) +} diff --git a/packages/util/paths/tests/paths.spec.ts b/packages/util/paths/tests/paths.spec.ts new file mode 100644 index 0000000000..97e91a556e --- /dev/null +++ b/packages/util/paths/tests/paths.spec.ts @@ -0,0 +1,34 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + DEFAULT_DSH_HOME_DISPLAY, + DSH_HOME_DIR_NAME, + defaultDshHome, + expandHomePath, + resolveDshHome, +} from '@deepseek-ai/dsh-paths' + +describe('dsh path helpers', () => { + it('owns the shared default DSH home directory name', () => { + expect(DSH_HOME_DIR_NAME).toBe('.dsh') + expect(DEFAULT_DSH_HOME_DISPLAY).toBe('~/.dsh') + expect(defaultDshHome()).toBe(join(homedir(), '.dsh')) + }) + + it('expands tilde paths without changing non-tilde paths', () => { + expect(expandHomePath('~')).toBe(homedir()) + expect(expandHomePath('~/.dsh')).toBe(join(homedir(), '.dsh')) + expect(expandHomePath('~\\.dsh')).toBe(join(homedir(), '.dsh')) + expect(expandHomePath('/tmp/.dsh')).toBe('/tmp/.dsh') + expect(expandHomePath('~other/.dsh')).toBe('~other/.dsh') + }) + + it('resolves explicit DSH home before environment and default locations', () => { + const envHome = join(homedir(), 'env-dsh') + + expect(resolveDshHome(undefined, { DSH_HOME: '~/env-dsh' })).toBe(envHome) + expect(resolveDshHome('/tmp/explicit-dsh', { DSH_HOME: '~/env-dsh' })).toBe('/tmp/explicit-dsh') + expect(resolveDshHome(undefined, {})).toBe(defaultDshHome()) + }) +}) diff --git a/packages/util/paths/tsconfig.json b/packages/util/paths/tsconfig.json new file mode 100644 index 0000000000..749cb0208e --- /dev/null +++ b/packages/util/paths/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1a3a78030..9cfb93c4d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,6 +278,52 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/context/workspace-context: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../fs/tool-fs + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + packages/cordis/tool-cordis: dependencies: schemastery: @@ -473,6 +519,9 @@ importers: '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../ui/user-interaction + '@deepseek-ai/dsh-workspace-context': + specifier: workspace:^ + version: link:../../context/workspace-context cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) @@ -495,6 +544,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -528,6 +580,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-workspace-context': + specifier: workspace:^ + version: link:../../context/workspace-context cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -586,6 +641,9 @@ importers: '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../ui/user-interaction + '@deepseek-ai/dsh-workspace-context': + specifier: workspace:^ + version: link:../../context/workspace-context cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) @@ -1719,6 +1777,12 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/util/paths: + devDependencies: + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/util/timeout: devDependencies: cordis: @@ -2011,6 +2075,9 @@ importers: '@deepseek-ai/dsh-llm-pi-ai': specifier: workspace:^ version: link:../../packages/llm/llm-pi-ai + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../packages/util/paths '@deepseek-ai/dsh-permission': specifier: workspace:^ version: link:../../packages/ui/permission @@ -2131,6 +2198,9 @@ importers: '@deepseek-ai/dsh-workflow-workerthread': specifier: workspace:^ version: link:../../packages/workflow/workflow-workerthread + '@deepseek-ai/dsh-workspace-context': + specifier: workspace:^ + version: link:../../packages/context/workspace-context cordis: specifier: workspace:^ version: link:../../vendor/cordis diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index b4e7981f10..00f0fe0035 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 5525e8a7b88df3f686bb1fa3a08c3556acc2e655 -README.zh.md: 30723d6d78b84e16261898a88ed22c3c58fc83bf +README.md: cdf38d4474a0e0148a4804e14c12971c76b38e27 +README.zh.md: 99d57c6f900371a46b94c5ea80b2a1665fd5e8d1 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 5525e8a7b8..cdf38d4474 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -26,4 +26,4 @@ Each wheel contains exactly one executable. The fixed tags are `py3-none-manylin ## Zero-config design -The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving surface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` with the JSON-RPC serving entry, agent core, a preloaded DeepSeek adapter, JSONL persistence, and local bash. The adapter reads `DEEPSEEK_API_KEY` and `DEEPSEEK_BASE_URL`, while persistence and bash use `DSH_SESSION_ROOT` and `DSH_CWD` with manual-run fallbacks. When the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](../sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. +The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving surface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` with the JSON-RPC serving entry, agent core, a preloaded DeepSeek adapter, JSONL persistence, local bash, and a local filesystem provider for bounded workspace-instruction loading. The adapter reads `DEEPSEEK_API_KEY` and `DEEPSEEK_BASE_URL`, while persistence, bash, and the filesystem provider use `DSH_SESSION_ROOT` and `DSH_CWD` with manual-run fallbacks. When the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](../sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 30723d6d78..99d57c6f90 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -26,4 +26,4 @@ exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deep ## 零配置设计 -运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一硬语义是运行时设计的一部分,本包不软化它。`bin`(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它,启动出的 agent 就没有对外通道。本包检入的 `runtime/cordis.yml` 包含 JSON-RPC 服务条目、`agent-core`、预载的 DeepSeek 适配器、JSONL 持久化与本地 bash。DeepSeek 适配器读取 `DEEPSEEK_API_KEY` 与 `DEEPSEEK_BASE_URL`,持久化与 bash 则使用 `DSH_SESSION_ROOT` 和 `DSH_CWD`,并为手动运行提供回退值。调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。 +运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一硬语义是运行时设计的一部分,本包不软化它。`bin`(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它,启动出的 agent 就没有对外通道。本包检入的 `runtime/cordis.yml` 包含 JSON-RPC 服务条目、`agent-core`、预载的 DeepSeek 适配器、JSONL 持久化、本地 bash,以及用于有界加载工作区指令的本地文件系统 provider。DeepSeek 适配器读取 `DEEPSEEK_API_KEY` 与 `DEEPSEEK_BASE_URL`,持久化、bash 和文件系统 provider 则使用 `DSH_SESSION_ROOT` 和 `DSH_CWD`,并为手动运行提供回退值。调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 7fba79083a..a9c0055a02 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -33,6 +33,7 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", @@ -70,6 +71,7 @@ "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", "@deepseek-ai/dsh-web-search-exa": "workspace:^", "@deepseek-ai/dsh-web-search-perplexity": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "cordis": "workspace:^" diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml index a47dcc26a7..ac61a8eb15 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml @@ -9,6 +9,9 @@ # Agent spine; the SDK server creates agents per sessionId. - id: agent-core name: '@deepseek-ai/dsh-agent-spine-demo' + config: + workspaceContext: + maxBytes: 65536 # Stock DeepSeek adapters. Loading requires an API key; initialize and shutdown # may use a dummy key because they do not call the model. @@ -32,3 +35,10 @@ name: '@deepseek-ai/dsh-bash-local' config: cwd: !!js process.env.DSH_CWD ?? process.cwd() + +# Local filesystem provider for workspace instruction loading. This does not +# expose model-facing file tools by itself. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py index 0ee373102e..adbc99d867 100644 --- a/python/sdk/tests/test_bundled_runtime.py +++ b/python/sdk/tests/test_bundled_runtime.py @@ -22,6 +22,8 @@ _CORDIS_YML = """\ name: '@deepseek-ai/dsh-jsonrpc' - id: agent-core name: '@deepseek-ai/dsh-agent-spine-demo' + config: + workspaceContext: false - id: sessions name: '@deepseek-ai/dsh-session-persistence-jsonl' config: diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 31df485e1d..41f1c9db58 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -860,7 +860,7 @@ function renderToolPipeline(): string { ` owned["Tool-owned session events
${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`, ` post["${mermaidCode('tools/post-execute')} waterfall
accept, block, replace, add context"]`, ` final["${mermaidCode('tools/result')} synchronous notification
frozen authoritative outcome"]`, - ' context["Buffered additionalContext
context/message after all tool results"]', + ' context["Buffered additionalContexts
context/message after all tool results"]', ` toolResult["Session event: ${mermaidCode('tool/result')}
single model-facing outcome"]`, ' allResults["All calls in the step settled
and tool/result events recorded"]', ' presentResult["UI completed card
presentResult(args, result)"]', @@ -888,7 +888,7 @@ function renderToolPipeline(): string { ' allResults --> context', '```', '', - 'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContext` to preserve call/result adjacency.', + 'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.', '', ...maintenanceFooter(maintenance), ].join('\n') diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index be9d36a6cd..918505693b 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -57,6 +57,7 @@ CUSTOM_CORDIS = """\ - id: agent-core name: '@deepseek-ai/dsh-agent-spine-demo' config: + workspaceContext: false tools: mode: both - id: sessions diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index c3e4533ba5..11d00ec714 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -10,6 +10,7 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "InjectOptions", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" }, @@ -30,6 +31,7 @@ { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "ContextEnvelope", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "EpochHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "TodoItem", "source": "packages/core/session/src/types.ts" }, @@ -60,6 +62,7 @@ { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRunContext", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRestriction", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" }, @@ -114,6 +117,7 @@ { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPathInfo", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsDirEntry", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" }, diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 8220324210..b968c2e679 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -30,6 +30,7 @@ interface SentenceContract { const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { 'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.', 'packages/util/brand': 'The package is a type-only primitive erased at compile time.', + 'packages/util/paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.', } /** diff --git a/tsconfig.base.json b/tsconfig.base.json index cf45d66cca..1542f78ffe 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -41,6 +41,7 @@ // explicit — TS project references have no wildcard form. "@deepseek-ai/dsh-*": [ "./packages/core/*/src", + "./packages/prompt/*/src", "./packages/llm/*/src", "./packages/bash/*/src", "./packages/code-runtime/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index 3a57169005..4fece2e6f6 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -11,6 +11,7 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, { "path": "./packages/llm/llm" }, { "path": "./packages/core/session" }, @@ -30,6 +31,7 @@ { "path": "./packages/skill/skill-local" }, { "path": "./packages/skill/tool-skill" }, { "path": "./packages/ui/tool-ask-user" }, + { "path": "./packages/context/workspace-context" }, { "path": "./packages/core/agent-loop" }, { "path": "./packages/examples/agent-spine-demo" }, { "path": "./packages/bash/bash" }, diff --git a/tsconfig.json b/tsconfig.json index 6585a987d6..0a73f16ea4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,7 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, { "path": "./packages/llm/llm" }, { "path": "./packages/core/session" }, @@ -41,6 +42,7 @@ { "path": "./packages/skill/skill-local" }, { "path": "./packages/skill/tool-skill" }, { "path": "./packages/ui/tool-ask-user" }, + { "path": "./packages/context/workspace-context" }, { "path": "./packages/core/agent-loop" }, { "path": "./packages/examples/agent-spine-demo" }, { "path": "./packages/bash/bash" },