diff --git a/AGENTS.md b/AGENTS.md index 23d7c0263b..22171afd74 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 @@ -59,6 +60,10 @@ pnpm run demo:cordis # self-referential demo: the agent modifies its own runt pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY) ``` +### Host sandbox failures + +When required `gh`, `pnpm`, build, test, or generator commands fail because the agent sandbox blocks credentials, network, IPC, file watching, or nested `sandbox-exec`, retry unchanged with the narrowest host escalation before diagnosing authentication or project failure. Require sandbox evidence; never bypass genuine test failures or the product sandbox under test. + ### Run the CI gates locally before marking a PR ready Run narrow checks during implementation and this CI-equivalent sequence before marking a PR ready. Fresh worktrees need `pnpm run build` before publint and NodeNext inspect `lib/`: diff --git a/docs/architecture.md b/docs/architecture.md index 848980a45a..9f2107907e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,7 +35,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry + generic `task_*` control tools | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred logical-corpus and exact-event reads | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred logical-corpus exact reads and relationship traces | ## Event @@ -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. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. 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 ``` -The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn instead of shipping a hole. `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 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/capability-seams.md b/docs/capability-seams.md index af4beab19e..2780299d0e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -25,7 +25,7 @@ flowchart LR pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] pkg_acp["acp"] - svc_sessionQuery["ctx.sessionQuery
Exact session-history reads"] + svc_sessionQuery["ctx.sessionQuery
Exact session-history reads and traces"] pkg_system_prompt["system-prompt"] svc_systemPrompt["ctx.systemPrompt
System prompt assembly registry"] pkg_tools["tools"] @@ -84,6 +84,10 @@ flowchart LR pkg_web_search_perplexity["web-search-perplexity"] pkg_web_search_deepseek["web-search-deepseek"] pkg_web_fetch_local["web-fetch-local"] + pkg_spill["spill"] + svc_spillStore["ctx.spillStore
Spill storage seam"] + pkg_spill_local["spill-local"] + pkg_spill_policy["spill-policy"] pkg_workflow["workflow"] svc_workflows["ctx.workflows
Workflow script engine"] pkg_workflow_workerthread["workflow-workerthread"] @@ -116,6 +120,8 @@ flowchart LR pkg_session_query --> svc_sessionQuery pkg_skill --> svc_skills pkg_skill_local --> svc_skills + pkg_spill --> svc_spillStore + pkg_spill_local --> svc_spillStore pkg_stdio_demo --> svc_userInteraction pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents @@ -161,6 +167,7 @@ flowchart LR svc_sessions --> pkg_session_query svc_sessions --> pkg_subagent_inprocess svc_skills --> pkg_tool_skill + svc_spillStore --> pkg_spill_policy svc_subagents --> pkg_tool_subagent svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs @@ -192,7 +199,7 @@ flowchart LR | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads. | +| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | @@ -209,6 +216,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-mock`](../packages/support/subagent-mock) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. | | `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | +| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow) | - | One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents. | Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e346e0114d..af01b7de9e 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` @@ -634,7 +637,7 @@ Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:3 Requires: `sessions` ```ts config-catalog -/** Configuration for exact session-query reads. */ +/** Configuration for exact session-query reads and traces. */ export interface Config { /** Maximum accepted raw read context on either side. Defaults to 50. */ readWindowMax?: number @@ -673,6 +676,40 @@ export interface Config { Source: [`packages/skill/skill-local/src/index.ts:39`](../packages/skill/skill-local/src/index.ts) +## `@deepseek-ai/dsh-spill-local` + +```ts config-catalog +/** Plugin config (all optional — `static Config` supplies the defaults). */ +export interface Config { + /** + * Root directory for spill files. Omitted uses a lazily-created private + * (0700) per-process directory under the OS temp dir — the safe default for + * a local deployment. Set it to keep spill files under a known location. + */ + root?: string +} +``` + +Source: [`packages/spill/spill-local/src/index.ts:22`](../packages/spill/spill-local/src/index.ts) + +## `@deepseek-ai/dsh-spill-policy` + +Requires: `tools` + +```ts config-catalog +/** Plugin config. */ +export interface Config { + /** + * The model-facing context cap for a plain-text tool result, in UTF-8 bytes. + * Omitted disables the policy entirely (no-op). When set, a result larger than + * this is spilled and replaced with a preview derived from this same budget. + */ + maxInlineBytes?: number +} +``` + +Source: [`packages/spill/spill-policy/src/index.ts:45`](../packages/spill/spill-policy/src/index.ts) + ## `@deepseek-ai/dsh-stdio` Requires: `agents` · `userInteraction` @@ -727,12 +764,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` @@ -933,6 +972,28 @@ export interface Config { Source: [`packages/fs/tool-fs/src/index.ts:22`](../packages/fs/tool-fs/src/index.ts) +## `@deepseek-ai/dsh-tool-fs-search` + +Requires: `tools` · `systemPrompt` · `bash` + +```ts config-catalog +/** Plugin config (all optional — `Config` supplies the defaults). */ +export interface Config { + /** Max paths one `glob` call retains inline; later paths go to the formatted spill file. */ + globMaxResults?: number + /** Max flat matches one `grep` call retains inline; later matches go to the formatted spill file. */ + grepMaxMatches?: number + /** Max bytes retained for one matched-line preview (the cut preserves UTF-8 boundaries). */ + grepMaxLineBytes?: number + /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ + rawOutputMaxBytes?: number + /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */ + timeoutMs?: number +} +``` + +Source: [`packages/fs/tool-fs-search/src/index.ts:59`](../packages/fs/tool-fs-search/src/index.ts) + ## `@deepseek-ai/dsh-tool-skill` Requires: `tools` · `skills` @@ -1073,7 +1134,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 +1306,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. @@ -1271,6 +1352,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) +- `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) - `@deepseek-ai/dsh-workflow` — abstract `WorkflowService` ([`packages/workflow/workflow/src/index.ts`](../packages/workflow/workflow/src/index.ts)) ## Library packages (no plugin entry) @@ -1286,6 +1368,8 @@ 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-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) 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..460e998b74 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` @@ -174,15 +175,17 @@ Source: [`packages/session-persistence/session-persistence/src/index.ts:30`](../ ## `ctx.sessionQuery` — `SessionQueryService` -Live-preferred logical-corpus and exact-event read service. +Live-preferred logical-corpus exact-read and relationship-tracing service. ```ts cordis-catalog listSessions(): Promise async listEvents(sessionId: SessionId): Promise +async traceSession(sessionId: SessionId): Promise +async traceEvent(request: SessionEventTraceRequest): Promise async readEvent(request: SessionEventReadRequest): Promise ``` -Source: [`packages/session-query/session-query/src/index.ts:35`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:38`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessions` — `SessionStore` @@ -201,7 +204,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:540`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` @@ -216,6 +219,22 @@ async get(name: string, options: SkillLookupOptions = {}): Promise +``` + +Source: [`packages/spill/spill/src/index.ts:45`](../../packages/spill/spill/src/index.ts) + ## `ctx.subagents` — `SubagentService` Named provider registry and capability-checked start surface. @@ -276,7 +295,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/bash.md b/docs/core-data-structures/bash.md index 3f2602e613..a8096c3b5c 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -6,7 +6,7 @@ Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.t ## Request vs. spec: the `resolve()` split -The seam separates the **model-/plugin-facing request** (optional `workdir`/`timeoutMs`, filled from config) from the **fully-resolved spec** the executor acts on (those fields required). The tool layer calls `ctx.bash.resolve(request)` between them — this is the repo's "explicit > implicit at package seams" rule made concrete: the reader of a `BashExecSpec` never wonders where the working directory came from. +The seam separates the **model-/plugin-facing request** (optional `workdir`/`timeoutMs`/`stdoutMaxBytes`, filled from config or request policy) from the **fully-resolved spec** the executor acts on (those fields required). The tool layer calls `ctx.bash.resolve(request)` between them — this is the repo's "explicit > implicit at package seams" rule made concrete: the reader of a `BashExecSpec` never wonders where the working directory or output budget came from. ```ts type-equiv interface BashExecRequest { @@ -15,6 +15,13 @@ interface BashExecRequest { workdir?: string | undefined /** Timeout override in milliseconds (implementations cap it). */ timeoutMs?: number | undefined + /** + * Foreground stdout capture budget in bytes. Absent uses the executor's + * default output cap. Trusted in-process consumers use this when they must + * parse complete stdout up to their own bounded limit; the model-facing bash + * tool does not expose it as a parameter. + */ + stdoutMaxBytes?: number | undefined /** Abort signal — implementations kill the command when it fires. */ signal?: AbortSignal | undefined /** @@ -57,6 +64,11 @@ interface BashExecSpec { command: string workdir: string timeoutMs: number + /** + * Resolved foreground stdout capture budget in bytes. `run()` uses it for + * stdout; background tasks and stderr keep the executor's own output cap. + */ + stdoutMaxBytes: number /** Abort signal — implementations kill the command when it fires. */ signal?: AbortSignal | undefined /** @@ -88,6 +100,8 @@ interface BashExecSpec { `stdin` and `env` are trusted in-process plugin inputs and are not exposed by `dsh-tool-bash`. The local executor scrubs ambient credentials before merging explicit caller-supplied env. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). +`stdoutMaxBytes` is also trusted-plugin-only. It lets a foreground consumer request complete stdout up to a bounded parser budget without changing stderr, background tasks, or the model-facing bash tool's ordinary output cap. + ## Foreground runs: `BashRunResult` The outcome of one completed (or killed) foreground run. Orthogonal outcomes are reported **independently** — a process can both time out AND exit 0 because it trapped the signal — so `timedOut`, `aborted`, `signal`, and `exitCode` are each their own field; a caller never reads a cut-short run as a clean success. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 340212d37a..f92c1427c7 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -19,7 +19,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | -| [session-query.md](session-query.md) | logical session/event records and bounded exact-event reads | +| [session-query.md](session-query.md) | logical records, bounded exact-event reads, and relationship traces | | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, and the guarded execution pipeline | | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | @@ -32,6 +32,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface | | [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | | [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` | +| [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` | | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | > Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts. @@ -246,6 +247,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 @@ -282,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` @@ -293,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()`: @@ -353,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) @@ -361,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 a drained queued message — optionally rewriting its `content` or attaching `additionalContext` — or block it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`): +`agent/prompt-submit` returns a `PromptDecision` (allow a drained queued message — optionally rewriting its `content` or attaching `additionalContexts` — or block it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`): ```ts type-equiv type PromptDecision = - | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext } + | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'block'; reason: string } ``` -`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern): +`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn and therefore carries no context envelope or metadata — the typed `/goal` pattern): ```ts type-equiv type ContinuationDecision = | { action: 'stop' } - | { action: 'continue'; reason?: HookContext } + | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } ``` `agent/turn-stop` returns the stop-only `ContinuationStop` subset or `undefined`. The loop calls this serial checkpoint after folding the ordinary decision, its reason, and pending steering; a stop is terminal and discards pending steering. 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-query.md b/docs/core-data-structures/session-query.md index ded8ca3f7e..86d2259f7f 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -1,6 +1,6 @@ # Session Query -Exact reads over the live-preferred logical session corpus. The [package contract](../../packages/session-query/session-query) owns source precedence, dynamic optional persistence, cloning, surface classification, bounded windows, and typed failures. Full-text search is a separate proposed SQLite phase. +Exact reads and relationship traces over the live-preferred logical session corpus. The [package contract](../../packages/session-query/session-query) owns source precedence, dynamic optional persistence, cloning, surface classification, bounded windows, tracing validation, and typed failures. Full-text search is a separate proposed SQLite package. Source: [`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts) @@ -30,6 +30,34 @@ export interface SessionEventRecord { } ``` +## Session lineage + +`SessionLineageTrace` carries known parents in immediate-to-outward order and a forest of recursively nested direct descendants. The completeness discriminant makes a known root and a missing parent mutually exclusive. + +```ts type-equiv +export interface SessionLineageNode { + session: SessionRecord + descendants: SessionLineageNode[] +} +``` + +```ts type-equiv +export type SessionLineageTrace = { + target: SessionRecord + ancestors: SessionRecord[] + descendants: SessionLineageNode[] +} & ( + | { + complete: true + root: SessionRecord + } + | { + complete: false + unresolvedParentId: SessionId + } +) +``` + ## Bounded event reads The request addresses one raw seq and optional neighboring counts. The result carries a `SessionHeader` rather than availability flags so a known live target can remain independent of persistence health. @@ -53,6 +81,28 @@ export interface SessionEventWindow { } ``` +## Event relationships + +Event traces distinguish positional surface replacement from logged provenance. Every seq list contains direct links except `replacementChain`, which follows immediate replacers from the target to the final positional replacement. + +```ts type-equiv +export interface SessionEventTraceRequest { + sessionId: SessionId + seq: number +} +``` + +```ts type-equiv +export interface SessionEventTrace { + target: SessionEventRecord + replacedBy?: number + replacementChain: number[] + replacedEventSeqs: number[] + sourceEventSeqs: number[] + derivedEventSeqs: number[] +} +``` + ## Errors The closed code union distinguishes request validation, missing targets, malformed surface logs, optional-backend failure, and contradictory source metadata. @@ -61,6 +111,7 @@ The closed code union distinguishes request validation, missing targets, malform export type SessionQueryErrorCode = | 'SESSION_QUERY_EVENT_NOT_FOUND' | 'SESSION_QUERY_INVALID_CONFIG' + | 'SESSION_QUERY_INVALID_LINEAGE' | 'SESSION_QUERY_INVALID_SURFACE' | 'SESSION_QUERY_INVALID_WINDOW' | 'SESSION_QUERY_PERSISTENCE_FAILED' diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index d8292c49b3..a8738ae431 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. @@ -30,9 +38,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 } /** @@ -223,7 +238,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/spill.md b/docs/core-data-structures/spill.md new file mode 100644 index 0000000000..4e8ced8258 --- /dev/null +++ b/docs/core-data-structures/spill.md @@ -0,0 +1,56 @@ +# Spill Storage + +The spill storage seam — a [capability seam](../rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md) that persists a tool's oversized text and returns a model-facing locator plus retrieval guidance, split across packages: interface ([dsh-spill](../../packages/spill/spill), `ctx.spillStore`), implementation ([dsh-spill-local](../../packages/spill/spill-local), private session-scoped files on the host filesystem), and consumer ([dsh-spill-policy](../../packages/spill/spill-policy), the `tools/post-execute` policy). Spill is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Preview mechanics stay in [dsh-retention](../../packages/util/retention); this seam only saves the final text the policy hands it. + +Source: [`packages/spill/spill/src/types.ts`](../../packages/spill/spill/src/types.ts) + +## The save request + +`saveText` is the whole seam: persist `content` verbatim, return an opaque locator, a backend-supplied retrieval hint, and the exact byte count. The request carries the save-time storage namespace (`owner`), WHERE it came from (`source`, descriptive provenance for naming and inspection — not access control), and a `suggestedName` the backend may use as a naming hint (it is not a path). + +```ts type-equiv +interface SaveTextSpill { + owner: SpillOwner + source: SpillSource + suggestedName: string + content: string +} +``` + +```ts type-equiv +interface SpillOwner { + sessionId: SessionId +} +``` + +`SpillOwner.sessionId` is the save-time storage namespace. Forked sessions inherit existing spill locators from the seeded log; those artifacts are not copied or re-owned, and spills produced after the fork use the child session id. A retention-period cleanup may expire old locators with other old session artifacts; the spill seam does not define a per-session cleanup policy. + +```ts type-equiv +interface SpillSource { + toolName: string + callId: CallId + label: string +} +``` + +## The result + +```ts type-equiv +interface SpillRef { + locator: SpillLocator + bytes: number + retrievalHint: string +} +``` + +`SpillLocator` is a [branded](core.md#branded-ids) model-facing handle returned by the backend. The local backend renders it as a filesystem path; a remote or database backend can render a URI, key, or command token. Consumers treat it as opaque and render it with `retrievalHint` instead of assuming `read` is always the right retrieval mechanism. + +```ts type-equiv +type SpillLocator = Branded<'SpillLocator'> +``` + +## The service + +`SpillStore` (`ctx.spillStore`, defined in [`packages/spill/spill/src/index.ts`](../../packages/spill/spill/src/index.ts)) is a one-method abstract service: `saveText(input) → Promise`. It persists the FULL `content` and REJECTS on a real storage failure (permissions, ENOSPC, backend unavailable). The seam owns storage only: no retention policy, no tool-result replacement, no retrieval/search API. + +The local backend ([dsh-spill-local](../../packages/spill/spill-local)) writes under `/session-/-` — a configured or lazily-created private (0700) root, a `sha256(sessionId)` session subdir, and an exclusive owner-only (`open(path, 'wx', 0o600)`) write so a planted symlink cannot redirect it. Its `locator` is the local path and its `retrievalHint` tells the model to use `read` or `grep` on that path. The policy consumer ([dsh-spill-policy](../../packages/spill/spill-policy)) replaces an over-`maxInlineBytes` plain-text final result with a retention-library head/tail preview plus the spill reference, best-effort: a save failure keeps the original inline result rather than turning a successful call into an `isError`. 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..96ee6e5be9 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), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:80`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `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 df4e680ee0..ba67ef3f87 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -9,6 +9,8 @@ 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_retention["retention"] pkg_timeout["timeout"] end subgraph group_llm["packages/llm"] @@ -35,6 +37,7 @@ flowchart TD pkg_fs_local["fs-local"] pkg_fs_policy["fs-policy"] pkg_tool_fs["tool-fs"] + pkg_tool_fs_search["tool-fs-search"] end subgraph group_skill["packages/skill"] pkg_skill["skill"] @@ -62,6 +65,11 @@ flowchart TD pkg_web_search_exa["web-search-exa"] pkg_web_search_perplexity["web-search-perplexity"] end + subgraph group_spill["packages/spill"] + pkg_spill["spill"] + pkg_spill_local["spill-local"] + pkg_spill_policy["spill-policy"] + end subgraph group_timeout["packages/timeout"] pkg_timeout_policy["timeout-policy"] end @@ -108,6 +116,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"] @@ -171,6 +180,9 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_web_search_exa --> pkg_web pkg_web_search_perplexity --> pkg_web + pkg_spill --> pkg_brand + pkg_spill --> pkg_llm + pkg_spill --> pkg_session pkg_session_persistence --> pkg_session pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session @@ -182,6 +194,7 @@ flowchart TD pkg_compact_basic --> pkg_compact pkg_compact_basic --> pkg_llm pkg_compact_basic --> pkg_session + pkg_spill_local --> pkg_spill pkg_hook_protocol --> pkg_bash pkg_hook_protocol --> pkg_session pkg_session_persistence_jsonl --> pkg_session @@ -250,6 +263,13 @@ flowchart TD pkg_tool_fs --> pkg_session pkg_tool_fs --> pkg_system_prompt pkg_tool_fs --> pkg_tools + pkg_tool_fs_search --> pkg_bash + pkg_tool_fs_search --> pkg_llm + pkg_tool_fs_search --> pkg_retention + pkg_tool_fs_search --> pkg_session + pkg_tool_fs_search --> pkg_spill + pkg_tool_fs_search --> pkg_system_prompt + pkg_tool_fs_search --> pkg_tools pkg_tool_skill --> pkg_agent pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill @@ -262,6 +282,11 @@ flowchart TD pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools pkg_tool_web --> pkg_web + pkg_spill_policy --> pkg_llm + pkg_spill_policy --> pkg_retention + pkg_spill_policy --> pkg_session + pkg_spill_policy --> pkg_spill + pkg_spill_policy --> pkg_tools pkg_timeout_policy --> pkg_llm pkg_timeout_policy --> pkg_timeout pkg_timeout_policy --> pkg_tools @@ -293,6 +318,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 @@ -348,6 +379,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 @@ -367,6 +399,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 @@ -377,11 +410,14 @@ 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` | — | +| [`retention`](../packages/util/retention) | `util` | — | | [`timeout`](../packages/util/timeout) | `util` | — | | [`scope`](../packages/core/scope) | `core` | — | | [`skill`](../packages/skill/skill) | `skill` | — | @@ -412,11 +448,13 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`web`](../packages/web/web) | +| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | @@ -434,9 +472,11 @@ flowchart TD | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | +| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | @@ -444,6 +484,7 @@ flowchart TD | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`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) | @@ -454,9 +495,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 a27b19b9a7..1086623de7 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 — a drained message batch 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 a60579ef4c..25d4a7ca73 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 | @@ -78,8 +79,10 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Background subagent tasks](implemented/feature/2026-07-08-background-subagent-tasks.md) | 2026-07-08 | | [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 | | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | +| [Bash-backed grep and glob discovery tools](implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md) | 2026-07-09 | | [Exact session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 | | [Configure subagent persona, tool visibility, and depth](implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) | 2026-07-12 | +| [Session query relationship tracing](implemented/feature/2026-07-13-session-query-tracing.md) | 2026-07-13 | | [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 | ### Simplification @@ -145,8 +148,10 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Every LLM request is reconstructable from the session log](implemented/architecture/2026-07-05-reconstructable-requests.md) | 2026-07-05 | | [Subagent provider-lifecycle events — `subagent/provider-added` / `subagent/provider-removed`](implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md) | 2026-07-05 | | [A shared timeout/deadline primitive, with hard-kill left to each capability](implemented/architecture/2026-07-06-timeout-deadline-library.md) | 2026-07-06 | +| [Tool result retention library](implemented/architecture/2026-07-06-tool-result-retention-library.md) | 2026-07-06 | | [Tool-call timeout policy as a plugin](implemented/architecture/2026-07-07-tool-call-timeout-policy.md) | 2026-07-07 | | [The agent is a registration scope](implemented/architecture/2026-07-08-agent-scope-contexts.md) | 2026-07-08 | +| [Tool output spill policy](implemented/architecture/2026-07-08-tool-output-spill-files.md) | 2026-07-08 | | [Single-file executable SDK runtime distribution (single-exe)](implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) | 2026-07-10 | | [Agent-scope runtime design and correctness](implemented/architecture/2026-07-12-agent-scope-runtime-design.md) | 2026-07-12 | 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/architecture/2026-07-06-tool-result-retention-library.md b/docs/rfc/implemented/architecture/2026-07-06-tool-result-retention-library.md new file mode 100644 index 0000000000..f35cf56e84 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-06-tool-result-retention-library.md @@ -0,0 +1,155 @@ +# RFC: Tool result retention library + +Status: implemented + +## Problem + +Several model-facing tools already bound the amount of context they return, but each one owns a different local mechanism and vocabulary: bash keeps a tail plus spill files, web search caps source lists, web fetch caps body content, and `glob` / `grep` discovery needs an inline first page while keeping exact omission metadata for the full result set. A single `truncate(text)` helper cannot cover those cases: item tools need item counts and grouping outside the primitive, while text tools need byte budgets and UTF-8-safe head/tail cuts. + +The shared abstraction the tools need is **retention**, not generic collection. A caller feeds items or text chunks into a bounded object and later receives the retained content plus exact omission metadata. Tool-specific code still owns business semantics: file grouping, line numbering, exit codes, provider error states, spill files, and model-facing prose. The common library owns only the mechanical question "what did we keep, and what did we omit?" + +## Decision + +`@deepseek-ai/dsh-retention` lives under `packages/util/` (peer to `dsh-brand` and `dsh-timeout`) and owns bounded model-facing output. It is a library of pure classes and functions, **not** a Cordis service or plugin: it takes no `ctx`, registers nothing, holds no cross-call state, and emits no events. Tool packages import it directly when they need bounded output. + +The library has two independent retainers: + +- `ItemRetainer` handles ordered logical units such as paths, grep matches, or search sources. It supports `head` retention only in v1, while keeping the retainer shape open to additional retention strategies later. +- `TextRetainer` handles byte-oriented text streams such as bash stdout/stderr or web response bodies. It supports `head`, `tail`, and `headTail` retention while preserving UTF-8 boundaries at `finish()`. + +Both retainers return a small `PushDecision` after each `push()` so callers can tell whether that unit/chunk was fully retained and whether the accumulated result is now truncated. Omission counts are exact because callers keep feeding every observed item/chunk. + +```ts ignore-check +/** + * How much content the retainer omitted. + * + * `unknown` is reserved for callers that omit without a count; the retainers + * themselves return `none` or `exact`. + */ +type Omitted = + | { kind: 'none' } + | { kind: 'exact'; count: number } + | { kind: 'unknown' } + +interface PushDecision { + kept: boolean + truncated: boolean +} + +/** + * Final result for ordered logical units. + */ +interface RetainedItems { + items: T[] + truncated: boolean + seen: number + kept: number + omitted: Omitted +} + +/** + * Final result for text streams. + * + * The returned `text` is safe to send to a formatter; the retainer does not add + * tool-specific headers, exit markers, XML tags, or recovery instructions. + */ +interface RetainedText { + text: string + truncated: boolean + omittedBytes: Omitted +} +``` + +### Strategies + +Item retention supports a head window. Text retention supports head, tail, and headTail byte windows. + +```ts ignore-check +type ItemRetentionStrategy = + | { + /** Keep the first `maxItems` units. Use for `glob`, `grep`, and web sources. */ + kind: 'head' + maxItems: number + } + +type TextRetentionStrategy = + | { + /** Keep the first `maxBytes` bytes. */ + kind: 'head' + maxBytes: number + } + | { + /** Keep the final `maxBytes` bytes. Requires reading to the end. */ + kind: 'tail' + maxBytes: number + } + | { + /** Keep a stable prefix and suffix, omitting the middle. Requires reading to the end. */ + kind: 'headTail' + headBytes: number + tailBytes: number + } +``` + +### Tool mapping + +`read` is intentionally outside the v1 retention library. Its `read-render` helper owns a file-specific pagination contract: `offset` / `limit`, line numbers, `totalLines`, offset-out-of-range errors, per-line preview truncation, and a selected-output byte cap that can stop scanning mid-window. That is a line-window renderer, not a generic retention primitive. It may share future neutral notice helpers, but it should not pass its already-selected window through `ItemRetainer`. + +`FsGlobEntry` and `FlatGrepMatch` below are the intended discovery-tool item shapes, not existing retention-library exports. `FsGlobEntry` is one backend-derived path, and `FlatGrepMatch` is one ungrouped grep match before the backend groups retained matches by file. + +`glob` uses `ItemRetainer` with `{ kind: 'head', maxItems: globMaxResults }` after collecting the full sorted path list. The tool keeps the retained first page inline and may save the full list through the spill seam. Path mapping, skipped candidates, and `incomplete` stay outside the retainer. + +`grep` uses `ItemRetainer` with `{ kind: 'head', maxItems: grepMaxMatches }` before grouping. The executor parses ripgrep output, maps paths, applies per-line preview truncation, and pushes flat matches. After `finish()`, the tool groups retained matches by file and can save the full match list through the spill seam when the inline result is capped. Grouping is not part of the retainer because the cap is total matches, not files; per-match preview truncation and `incomplete` are also separate from result-level retention. + +`bash` can use `TextRetainer` with `tail` or `headTail` and reads to process completion. The bash executor still owns spill files, exit status, signal, timeout, and background-task behavior; the retention helper only replaces ad hoc in-memory head/tail accounting where that behavior is desired. Long-running task ownership remains orthogonal to the [generic long-running tool runtime](2026-06-20-generic-long-running-tool-runtime.md). + +`web_fetch` can use `TextRetainer` with `head` or `headTail`, or keep provider-owned body caps when the provider must read and decode internally. Either way, the fetch result's `truncated` remains a provider/tool fact, and the library only supplies retained text and omission metadata. + +`web_search` can use `ItemRetainer` with `head`. Current providers often return an array, so this is post-hoc but still standardizes notices. + +### Notices + +The library exposes a neutral notice shape and a tiny formatter hook, but tools provide the user-facing words. A grep footer says "Narrow the pattern, path, or include"; a web fetch footer says "Fetch a more specific URL or section"; bash may point to a spill file. The retainer cannot know those recovery actions. + +```ts ignore-check +interface RetentionNotice { + scope: string + strategy: 'head' | 'tail' | 'headTail' + unit: 'items' | 'bytes' | 'chars' | 'lines' + limit: number | { head: number; tail: number } + kept: number + omitted: Omitted +} + +const formatGrepNotice = (notice: RetentionNotice): string => + formatRetentionNotice( + notice, + ({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`, + ) +``` + +The formatter hook is deliberately small: a tool turns a `RetentionNotice` into its own footer text. The helper may standardize omission wording, but it does not own recovery guidance. + +`truncated` means the retainer omitted otherwise-available content because of a budget. It does not mean the upstream was incomplete. Tools keep separate fields for permission failures, skipped binary files, provider partial failures, unreadable candidates, invalid UTF-8, and any other "could not inspect" condition. + +## Consequences + +**What shipped.** `@deepseek-ai/dsh-retention` exports `ItemRetainer`, `TextRetainer`, the result types (`RetainedItems`, `RetainedText`), the strategy types (`ItemRetentionStrategy`, `TextRetentionStrategy`), `Omitted`, `PushDecision`, `RetentionNotice`, and the neutral notice helpers `describeOmitted` / `formatRetentionNotice` — with no dependency on Cordis or any tool package. Unit tests cover item-head retention with exact omission counts, text-head retention, text-tail retention, head-tail byte retention, zero budgets, UTF-8 boundary handling (2-, 3-, and 4-byte codepoints and invalid lead bytes at each cut), and unknown omission wording. + +**What is documented but not yet migrated.** `glob`, `grep`, `bash`, `web_fetch`, and `web_search` have their mappings documented in the [package README](../../../../packages/util/retention/README.md), but not every tool has been migrated onto the library in this change; migration is deliberately separate follow-up work. `read` is documented as intentionally out of scope: its `read-render` line-window contract (`offset`/`limit`, `totalLines`, offset-range errors, per-line preview truncation, a byte cap over the selected window) is not generic retention, and one `Omitted` count cannot represent both sides of a line window. + +**Boundaries the library holds.** `truncated` means the retainer omitted otherwise-available content because of a budget; it never means the upstream was incomplete. Tool-specific states — `incomplete`, permission failures, provider partial failures, binary skips, bash spill-path recovery, invalid UTF-8 — stay in tool-domain fields, outside the retainer. When a future change migrates a tool, that package's README and tests must prove the model-facing result text is unchanged except for deliberate notice wording. + +**Tradeoffs accepted.** The v1 surface deliberately supports only item `head` retention and text `head` / `tail` / `headTail`; windows, grouped budgets, sort-aware caps, and upstream-stop control wait until a second consumer proves the need. Text retention counts bytes for process/body safety, leaving character- and line-level preview budgets as separate tool-owned concerns. + +## Alternatives considered + +**Post-hoc `truncate(text)` only.** Rejected: it matches Codex's history/tool-output truncation use case but loses item counts, grouping boundaries, UTF-8-safe byte windows, and exact omission metadata. + +**One generic `Collector` with pluggable callbacks.** Rejected for v1: it hides the two important resource modes. Logical item retention counts items; text retention counts bytes and preserves UTF-8 boundaries. Separate `ItemRetainer` and `TextRetainer` names make that difference explicit while keeping the API small. + +**Put `read` windowing behind `ItemRetainer`.** Rejected for v1: `read` is the only current window consumer, and its semantics are file pagination rather than generic retention. A single `Omitted` count cannot represent both sides of a line window, and `read` also carries `totalLines`, offset-range errors, per-line preview truncation, and a byte cap over selected output. Keeping `read-render` tool-owned avoids growing the shared library around one special case. + +**Make truncation part of `ToolExecutionResult`.** Rejected: the tool registry would have to understand tool-specific recovery guidance, grouping, line numbering, exit status, and provider semantics. Retention is a library used before a tool returns `ContentBlock[]`; the model-facing result remains tool-owned. + +**Expose limits in every model-facing tool schema.** Rejected as the default: Claude Code's grep exposes `head_limit` / `offset`, but this harness keeps routine budgets as deployment config unless the model genuinely needs pagination control. A future read-like continuation field can be added per tool; it does not belong in the shared retention primitive. diff --git a/docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md b/docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md new file mode 100644 index 0000000000..3a60c5c223 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -0,0 +1,189 @@ +# RFC: Tool output spill policy + +Status: implemented + +## Problem + +Tool outputs need bounded model-facing previews, but some oversized results are still useful later. A fetched page body or a verbose tool response should not consume the next model request in full, but the model should be able to inspect the complete formatted result later with existing file-reading tools. + +Before this change the behavior was uneven. `dsh-bash-local` already writes complete stdout/stderr streams to private temp spill files when its in-memory tail overflows, but ordinary text tool results were returned inline unless the tool hand-rolled its own cap. The [tool result retention library](./2026-07-06-tool-result-retention-library.md) owns preview mechanics, but it does not own storage or an execution-pipeline policy that applies those mechanics to final tool results. + +The shape matches the timeout policy design: a tool author normally returns the text result, and a policy plugin enforces the deployment's default context budget. Tool-specific early spill remains possible later for outputs that do not survive to the final `ToolExecutionResult`; the first cut proves the default final-result path. + +## Decision + +A thin spill storage seam plus a default spill policy plugin, in a new `packages/spill/` group: + +| Package | Role | +|---|---| +| `@deepseek-ai/dsh-spill` | Interface: `ctx.spillStore`, vocabulary types, no storage implementation. | +| `@deepseek-ai/dsh-spill-local` | Local backend: private, session-scoped file storage on the host filesystem. | +| `@deepseek-ai/dsh-spill-policy` | Tool-result policy plugin: wraps final text results after dispatch and replaces oversized results with a retained preview plus a spill locator. | + +There is no dedicated model-facing consumer package. The consumer is the existing `ctx.tools` execution pipeline: `dsh-spill-policy` consumes final tool results through the `tools/post-execute` waterfall, and the model follows the backend-supplied retrieval hint for the returned locator. + +### Spill seam + +The storage seam is minimal: save text and return a locator plus retrieval hint. + +```ts ignore-check +interface SpillStore { + saveText(input: SaveTextSpill): Promise +} + +interface SpillSource { + toolName: string + callId: CallId + label: string +} + +interface SaveTextSpill { + owner: { sessionId: SessionId } + source: SpillSource + suggestedName: string + content: string +} + +type SpillLocator = Branded<'SpillLocator'> + +interface SpillRef { + locator: SpillLocator + bytes: number + retrievalHint: string +} +``` + +`SpillLocator` is a [branded](../../../../packages/util/brand) model-facing handle returned by the backend. The local backend renders it as a filesystem path; a remote or database backend can render a URI, key, or command token. Consumers treat it as opaque and render it with `retrievalHint` instead of assuming `read` is always the right retrieval mechanism. `SpillOwner.sessionId` is the save-time storage namespace: forked sessions inherit existing spill locators from the seeded log without copying or re-owning them, and new spills after the fork use the child session id. A retention-period cleanup may expire old locators with other old session artifacts; the spill seam does not define a per-session cleanup policy. + +`dsh-spill-local` owns only storage details: session-scoped directory selection, safe names, path-traversal protection, the write, and returning `{ locator, bytes, retrievalHint }`. It does not own retention policy, tool-result replacement, search, or file inspection. Files land at `/session-/-`, where `root` is a configured path or a lazily-created private (0700) per-process temp dir, the session subdir is a short `sha256(sessionId)` prefix, and the leaf is a random hex prefix plus the caller's `suggestedName` sanitized to one path segment (mirrors the JSONL backend's `encodeSegment`). The write is `open(path, 'wx', 0o600)` — exclusive and owner-only, so a planted symlink cannot redirect it. The locator is the path, and the retrieval hint tells the model it can use `read` or `grep` on that path. + +### Spill policy + +`dsh-spill-policy` is a `tools/post-execute` result transformer with one configuration knob: + +```ts ignore-check +interface Config { + /** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */ + maxInlineBytes?: number +} +``` + +When `maxInlineBytes` is omitted the plugin registers nothing (a true no-op). When set, it applies a default policy to final plain-text tool results: + +1. Let the tool run normally, delegating via `next()` so a downstream listener settles the result first. +2. Flatten the accepted final `ContentBlock[]` only when it is entirely plain text; a result with any non-text block is left untouched. +3. If its UTF-8 byte size is at or below `maxInlineBytes`, leave it unchanged. +4. If it is larger, call `ctx.spillStore.saveText()` with the full final text. +5. Replace the model-facing result with a retained head/tail preview plus the spill reference. + +The preview is an implementation default owned by the policy: a head/tail split of `maxInlineBytes` via the retention library's `TextRetainer`. Future config can expose preview sizing only after a second deployment needs it. + +The replacement text is intentionally generic because the policy only knows the final formatted tool result, not the tool's internal resource: + +```text + + +(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.) +``` + +If `ctx.spillStore.saveText()` fails (permissions, ENOSPC, backend unavailable), or the call has no session owner, or no backend is loaded, the plugin logs the reason and returns the original result unchanged. Spill failure never turns a successful tool call into an `isError` result or hides the inline result. + +The policy skips `read` to avoid a circular `read -> spill file -> read again` loop. Additional opt-out configuration is deferred until a real second tool needs it. + +## Showcase: web_fetch + +`web_fetch` is the first showcase because it returns a naturally large text result and needs no tool-specific spill code. The tool is ordinary: + +```ts ignore-check +ctx.tools.register(defineTool({ + name: 'web_fetch', + async execute(args, exec) { + const result = await ctx.web.fetch({ url: args.url }, exec.signal ? { signal: exec.signal } : undefined) + return [{ type: 'text', text: formatFetchOutput(result) }] + }, +})) +``` + +With `dsh-spill-policy` configured, a large formatted fetch result is automatically retained and spilled. A deployment demonstrates the behavior by setting the provider resource cap higher than the policy cap: + +```yaml +- id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + config: + maxBodyChars: 500000 + +- id: spill-local + name: '@deepseek-ai/dsh-spill-local' + +- id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 50000 +``` + +This separation is important. `web-fetch-local` still owns resource caps (`maxResponseBytes`, `maxBodyChars`) to protect network, memory, and decoding work. `spill-policy` owns only the model-facing context cap after the result already exists. If the provider already returned `truncated: true`, the spill file contains the full formatted result the tool returned, not the full original webpage; the policy does not claim otherwise. + +## Relationship to retention and early spill + +Retention is separate from spill storage: + +- `@deepseek-ai/dsh-retention` owns preview mechanics (`TextRetainer`, `ItemRetainer`, and omitted metadata). +- `@deepseek-ai/dsh-spill` owns saving final text and returning a locator plus retrieval hint. +- `@deepseek-ai/dsh-spill-policy` applies the default final-result policy in the tool pipeline, composing the two. + +The final-result policy cannot replace tool-owned early spill. Some useful content is not present in final `ToolExecutionResult.content`: + +- `bash` final output is already a tail plus a temp spill path; the complete stdout/stderr streams live in executor files. +- `subagent` final output is the child final answer, not the child rollout. +- Future tools may produce runtime artifacts that are never represented by their final `ToolExecutionResult.content`. + +Those cases can consume `ctx.spillStore` directly in later work. They are not part of the first showcase. + +## Non-goals + +- No new model-facing `artifact_read` or `artifact_search` tool in v1. +- No per-tool retention configuration in v1. +- No model-facing timeout/truncation arguments. +- No migration of `read` output into spill files. +- No replacement for provider/resource caps such as `web-fetch-local.maxBodyChars`. +- No bash temp-file normalization or subagent rollout capture in the first cut. + +## Deferred + +- `saveFile()` / `linkOrCopy` for existing executor spill files, needed for bash normalization. +- Tool-owned spill for subagent rollouts (`await run.result`, read in-process child session before `run.dispose()`, save JSONL). +- Per-tool opt-out or per-tool policy declarations if the built-in `read` skip is insufficient. +- Remote or database storage backends for ACP or remote environments where a local path is not meaningful. +- Cleanup and retention policy for old spill files, likely tied to session cleanup. + +## Testing + +- `dsh-spill` unit tests pin the seam contract: registration as `ctx.spillStore`, one-implementation-per-context, and disposal release. +- `dsh-spill-local` unit tests cover `saveText`, `encodeSegment` sanitization (separators/tilde/whole-segment dots/empty), the session-hash directory, owner-only permissions, distinct paths per save, the configured/private root, and a storage-failure rejection. +- `dsh-spill-policy` unit tests drive real tools through `ctx.tools.execute`: disabled-mode no-op, oversized-text replacement, small/non-text passthrough, `read` skip, best-effort fallback (save failure / no backend / no owner), and downstream-composition (bounding a replaced result, preserving `additionalContexts`). +- `dsh-tool-web` integration drives `web_fetch` through `ctx.tools.execute` with the real `spill-local` backend + policy, proving the model-facing text changes only by the deliberate spill notice while the spill file holds the full formatted result. +- The `coding-agent` example loads `spill-local` + `spill-policy`, so its keyless Loader smoke exercises the real load path (the namespace-plugin export shape + `inject`). + +## Consequences + +The default policy only sees final formatted text. It cannot preserve provider-internal content that was already capped or runtime artifacts that were never part of the result. This is acceptable for the first cut because the showcase is final-result spill, not early spill; tool-owned early spill remains deferred work. + +Returning real paths from the local backend keeps v1 simple and matches proven agent-tool behavior, while the seam itself only promises an opaque locator plus retrieval hint so remote backends can return non-file locators. + +The local-backend value proposition depends on the existing `read`/`grep` tools being able to inspect the returned local path, even when the spill directory is outside the session cwd. That holds today because the filesystem policy records observations and write guards but does not confine reads to the workspace. A future workspace-confinement policy must either allow local spill paths explicitly or use a non-file spill backend whose retrieval hint points at a supported reader. + +**Snapshot gap.** No ACP snapshot scenario covers the transcript-visible `web_fetch` spill notice yet. The ACP snapshot harness replays keyless and cannot hit the live web, and a `web_fetch` spill requires a real over-cap HTTP body; a deterministic scenario would need a seeded loopback fetch target the replay tree does not currently wire (the examples do not load `tool-web` at all). The behavior is covered instead by the `dsh-tool-web` integration test against a loopback server. Closing the gap is follow-up work: wire `tool-web` + a seeded fetch target into the ACP example, then record a `web-fetch-spill` scenario. + +The policy can become too large if it starts owning tool-specific semantics. It stays narrow: plain-text final results only. Tool-owned early spill remains future work. + +## Alternatives considered + +**Require each tool to opt in with a retention declaration.** Rejected for v1: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single `maxInlineBytes` deployment knob is enough to prove the shape. + +**Make `tool-results` a broad tool-result platform.** Rejected: a broad package name invites retention policy, result replacement, preview wording, search, and early spill into one seam. The shared storage part is smaller: save text and return a locator plus retrieval hint. + +**Use `ctx.fs.writeText` or the model-facing `write` tool.** Rejected: workspace filesystem writes carry project-file semantics, write/edit policy, observation state, and user-facing side effects. Spill files are runtime artifacts, not model-authored workspace edits. The existing `read` tool may inspect them later, but creation belongs to the runtime spill seam. + +**Let `web-fetch-local` fetch without caps and rely on spill-policy.** Rejected: spill-policy runs after the final tool result exists and cannot protect network, memory, or decoding resources. Provider resource caps stay mandatory. + +**Merge retention into spill.** Rejected: retention and spill have different responsibilities. `TextRetainer`/`ItemRetainer` decide what preview is kept and what was omitted; spill storage only saves the final text the policy asks it to save. 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 ea371ad55a..8a2af04494 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -14,9 +14,9 @@ The canonical surface separates transformable policy, around-dispatch control, a **Agent events** (`dsh-agent`): - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. -- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below). +- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching separately sourced `additionalContexts[]`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below). -**`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. +**`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing content and source recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. It is not a `context/message`, so its type does not offer a context envelope or durable context metadata. ### The tool pipeline gives each phase one kind of authority @@ -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 fully blocked batch becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. Every veto also records `prompt/blocked` with the original prompt and reason, so mixed batches retain blocked inputs. Allowed `additionalContext` is injected into the open turn. +1. **Open the turn before prompt policy.** A fully blocked batch becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. Every veto also records `prompt/blocked` with the original prompt and reason, so mixed batches retain blocked inputs. Every allowed `additionalContexts` entry is injected into the open turn. -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/feature/2026-07-09-bash-backed-grep-glob-discovery.md b/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md new file mode 100644 index 0000000000..bc8a452c91 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md @@ -0,0 +1,166 @@ +# RFC: Bash-backed grep and glob discovery tools + +Status: implemented + +## Problem + +The harness needs model-facing `glob` and `grep` tools, but making them `ctx.fs` provider methods turns a local product convenience into a universal filesystem backend contract. Local workspace discovery is naturally a process-backed `rg` workflow; remote or virtual filesystem backends may expose their own search API, may not share a local `ripgrep` view, or may not support discovery at all. The v1 should not require every filesystem backend to implement search before the file read/write/edit seam has proven that need. + +Search output also has two distinct budgets. The tool needs enough raw `rg` output to compute a stable logical result, but the model should receive only a bounded preview plus a recovery path when the formatted result is larger than the inline budget. The generic spill policy only sees the final tool result, so it cannot recover matches that a search tool already omitted. Search therefore needs tool-owned retention and best-effort formatted-result spill. + +## Decision + +`glob` and `grep` are model-facing tools in `@deepseek-ai/dsh-tool-fs-search`, backed by the bash seam, not by new `ctx.fs` provider methods. The package registers model-facing filesystem discovery tools, but execution uses `ctx.bash.resolve(request)` followed by `ctx.bash.run(spec)` with fixed `rg` command templates assembled by the tool. The tool layer owns schemas, argument validation, shell quoting, result parsing, result formatting, retention, formatted-result spill handoff, and timeout declaration. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution across local, sandboxed, or remote bash implementations. + +The tools do not use `ctx.bash.start()` and do not create model-visible background tasks. They run as ordinary foreground tools from the agent loop's perspective: the tool call returns only after the `rg` command exits, times out, is aborted, or fails. `defineTool({ timeoutMs })` declares the cooperative tool-call budget, `@deepseek-ai/dsh-timeout-policy` enforces it through `exec.signal`, and the tool forwards that signal into the bash request before `resolve()` / `run()`. The bash backend's own timeout remains a second safety cap; whichever aborts first wins. + +The tools align `path` with Claude Code's search tools while binding resolution to the bash workdir, not to `ctx.fs`. The tool derives the bash request workdir from `exec.agent?.session.header.cwd`, mirroring `dsh-tool-bash` and `dsh-tool-fs`; when no session cwd exists, it omits `request.workdir` so the bash implementation applies its configured cwd or process cwd through `resolve()`. For `grep`, `path` is an optional ripgrep target and may be a file or directory; omitted means the resolved bash workdir. For `glob`, `path` is an optional directory search root; omitted means the resolved bash workdir. Relative `path` values resolve against that workdir. Returned paths are displayed relative to the resolved bash workdir when possible and are intended to be follow-up-readable only in co-located deployments where the bash workdir and filesystem `read` root are the same workspace. v1 documents that deployment requirement but does not perform runtime cross-service validation. Remote or virtual filesystem search is deferred until there is a shared workspace/root contract or a provider-specific search backend. + +The package does not inject `fs`. It injects `tools`, `systemPrompt`, and `bash`; it deliberately reads `spillStore` with `ctx.get('spillStore')` instead of static inject because formatted-result spill is optional. Existing `@deepseek-ai/dsh-tool-fs` deployments that only want `read` / `write` / `edit` do not need to load bash. + +### Package shape + +The v1 package stays small. Inside `@deepseek-ai/dsh-tool-fs-search`, the source layout is: + +```text +src/index.ts +src/glob.ts +src/grep.ts +src/search-core.ts +src/shell-quote.ts +``` + +`glob.ts` and `grep.ts` own their parameter validation, command construction, result parsing, formatting, and registration. `shell-quote.ts` is one shared helper because shell quoting is the safety boundary both tools must use; `search-core.ts` is the other (an implementation-time amendment to the original four-file plan): the `SEARCH_*` error vocabulary, the bash-run + raw-output acquisition, the formatted-spill handoff, and workdir-relative display are byte-identical between the two tools, and duplicating that delicate plumbing per tool is exactly the missed extraction the symmetry convention flags. Command builders must not hand-roll quoting or concatenate unquoted model-controlled values into the shell command. + +### Schemas and config + +`glob` exposes the small discovery shape: + +```ts +interface GlobArgs { + pattern: string + path?: string +} +``` + +`grep` exposes the OpenCode-style minimal shape: + +```ts +interface GrepArgs { + pattern: string + path?: string + include?: string +} +``` + +Routine budgets stay out of the model-facing schema. `@deepseek-ai/dsh-tool-fs-search` owns these defaulted, validated config fields: + +| Field | Default | Role | +|---|---:|---| +| `globMaxResults` | `100` | Max paths retained inline; matches Claude Code's default `GlobTool` result limit. | +| `grepMaxMatches` | `250` | Max flat matches retained inline; matches Claude Code's default `GrepTool` `head_limit`. | +| `grepMaxLineBytes` | `2000` | Max bytes retained for one matched-line preview, applied with `TextRetainer({ kind: 'head', maxBytes: grepMaxLineBytes })`. | +| `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout the tool will parse; matches Claude Code's ripgrep raw buffer. | +| `timeoutMs` | `30000` | Tool-call timeout attached to both tool definitions and enforced by `@deepseek-ai/dsh-timeout-policy`. | + +`globMaxResults` and `grepMaxMatches` use `ItemRetainer({ kind: 'head' })`. `grepMaxLineBytes` uses `TextRetainer({ kind: 'head', maxBytes: grepMaxLineBytes })` for each matched line so preview cuts preserve UTF-8 boundaries. This follows the [tool result retention library](../../implemented/architecture/2026-07-06-tool-result-retention-library.md) mapping for discovery items: collect the complete result, retain head items inline, and keep path mapping, grouping, and per-line preview outside the retainer. `grep` does not expose `case_insensitive`, `head_limit`, `offset`, `count`, multiline, context lines, output modes, or file type filters in v1. A model that needs surrounding context reads the matched file with `read`; a model that needs later results follows the returned spill locator's retrieval hint. + +The Claude Code values are reference points for the two-layer budget, not model-facing schema precedent. Its dedicated search tools buffer raw ripgrep output up to 20 MB for internal processing, use a 20-second ripgrep timeout on non-WSL platforms (60 seconds on WSL), then apply search-specific caps before the model sees a result: `GrepTool` defaults to `head_limit = 250` and persists formatted results above 20,000 characters, while `GlobTool` defaults to 100 paths and persists formatted results above 100,000 characters. This RFC mirrors the raw-buffer and inline-count defaults, chooses a 30-second default search timeout, and uses this harness's `ctx.spillStore.saveText()` path for formatted-result recovery. + +The `path` field follows the same split as Claude Code: `grep.path` is a file-or-directory ripgrep target, while `glob.path` is a directory search root. v1 does not expose a separate cwd/workdir argument on these tools. + +`include` is one positive glob filter, not a list and not an exclude syntax. Reject comma-separated or negated include patterns up front with a structured argument error. Every model-controlled value used in a shell command, including `pattern`, `path`, and `include`, must pass through the package-private shell quoting helper. + +### Execution + +`glob` builds a fixed `rg --files` command rooted at the resolved directory search root (`path` when supplied, else the bash workdir): `rg --files --glob --sort=modified --no-ignore --hidden`, plus VCS metadata excludes for `.git`, `.svn`, `.hg`, `.bzr`, `.jj`, and `.sl`. This aligns with Claude Code on hidden/ignored-file discovery and modified-time ordering while keeping VCS internals out of broad searches. The tool parses one path per line, maps results back to paths relative to the bash workdir when possible, pushes each path into `ItemRetainer({ kind: 'head', maxItems: globMaxResults })`, and formats the full sorted path list for a spill artifact when the retained result is capped. + +`grep` builds a fixed line-oriented `rg --json` command against the supplied file/directory target (`path` when supplied, else the bash workdir) so file path, line number, and line text are parsed without colon-splitting ambiguity. It consumes `match` records, treats malformed JSON or malformed match records as `SEARCH_FAILED`, maps result paths relative to the bash workdir when possible, applies per-line preview retention with `grepMaxLineBytes`, pushes each match into `ItemRetainer({ kind: 'head', maxItems: grepMaxMatches })`, then groups only the retained preview matches by file for inline output. The spill artifact stores the full formatted match list, not only the omitted tail, so the retrieval hint points at the same logical result the model saw. + +Raw `rg` stdout is an internal transport detail. The tool requests `stdoutMaxBytes: rawOutputMaxBytes` through `ctx.bash.resolve()` and parses `stdout.text` only when the executor returns untruncated stdout within that cap. If stdout is larger than `rawOutputMaxBytes`, or the executor still returns `stdout.truncated`, the tool fails with a clear search error telling the model to narrow `pattern`, `path`, or `include`. The tool never exposes raw `rg` output or bash raw spill paths to the model. + +Only stdout is a parse source. Stderr is diagnostic text for invalid patterns, missing `rg`, and search failures; if bash truncates stderr, the tool uses the retained stderr tail with a truncation note and does not read `stderr.spillPath`. + +If `ctx.bash.run()` reports `aborted` because the tool timeout or caller cancellation fired, the tool returns a structured failure rather than pretending there were no matches. If bash reports its own timeout first, the tool likewise fails with a clear timeout message. Nonzero ripgrep exit semantics are tool-owned: exit 0 is success with matches, exit 1 is success with no matches, invalid pattern / missing `rg` / inaccessible search workdir are failures. + +Search failures use a package-owned `HarnessError` subclass with `SEARCH_*` codes, not `FsErrorCode`, because these tools are not `ctx.fs` provider operations. The v1 vocabulary is `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, and `SEARCH_ABORTED`. Model argument validation failures such as missing required fields, blank strings, or unsupported negated/list `include` values remain ordinary tool argument errors. + +### Formatted result spill + +`ctx.spillStore` is optional and used only for model-facing formatted results. This is the first tool-owned spill call pattern in the codebase, and it is intentional because search retention is item-level policy: `globMaxResults` caps paths and `grepMaxMatches` caps matches while the tool still holds the complete logical result. The generic `dsh-spill-policy` caps final text bytes on `tools/post-execute`; by then a search tool would already have omitted later paths or matches, so the policy cannot recover them. + +When a search produces more logical results than the inline cap and `ctx.spillStore` is present, the tool saves the complete formatted result with `saveText()`. The spill owner is the calling agent's session header id (`exec.agent?.session.header.id`); without that owner, the search keeps the inline result and reports that the complete result could not be saved. The spill source is the tool execution identity: `{ toolName: exec.name, callId: exec.callId, label: 'result' }`. The suggested filenames are `grep-results.txt` and `glob-results.txt`; the spill backend still treats them as hints, never paths. + +When spill storage is absent, the call has no session owner, or saving fails, the tool still returns the inline page and a footer explaining that the complete result could not be saved. Search success must not turn into an `isError` result solely because formatted-result spill storage is unavailable. + +The bash raw output stream and the formatted search spill artifact are different artifacts. Raw `rg` stdout is parsed only in memory within the requested bash stdout cap; the formatted spill artifact is the stable model-facing recovery locator produced by `ctx.spillStore.saveText()`. + +### Result shape + +A capped `glob` result with successful formatted spill returns the inline page and a spill notice: + +```text + + +(Showing N of M paths. Full sorted result stored at: /.../session-abc123/9f8e7d-glob-results.txt. Use read with offset/limit, or grep this path to search within it.) +``` + +A capped `grep` result with successful formatted spill returns grouped preview matches and a spill notice: + +```text +Found N of M matches + + +Line 12: ... + +(Full grep result stored at: /.../session-abc123/9f8e7d-grep-results.txt. Use read with offset/limit, or grep this path to search within it.) +``` + +If the complete logical result fits under the inline cap, no formatted spill artifact is created. If the complete logical result is too large but formatted spill is unavailable, the footer says that the result was capped and the complete result could not be saved. The `truncated` / omitted count is a budget fact, not an incomplete-search fact; timeout, invalid regex, missing `rg`, inaccessible workdirs, raw-output overflow, binary skips, and parse failures stay in tool-domain error or incomplete fields. + +## Alternatives considered + +**Put `glob` / `grep` on `ctx.fs`.** Rejected for v1: it forces every filesystem backend to grow a search API and makes local ripgrep behavior part of the provider seam. Search is useful product behavior, but it is not a universal text-storage primitive like `readText` or `writeText`. + +**Directly spawn ripgrep from `dsh-fs-local`.** Rejected for this RFC's v1: direct spawn gives the cleanest argv boundary, stdout/stderr control, and early-stop control, but it duplicates process execution concerns that the bash seam already owns: environment scrubbing, process-group kill, timeout propagation, sandbox/remote executor substitution, and bounded output capture. It remains a reasonable optimization if bash-backed search proves too shell-string-sensitive or if foreground streaming becomes necessary. + +**Use `ctx.bash.start()` for streaming early stop.** Rejected: `start()` creates model-visible background task semantics: task ids, owner tokens, `bash_output`, `bash_kill`, completion notifications, and no built-in timeout. `grep` needs a foreground tool result, not a background bash workflow. If streaming search becomes necessary, the right abstraction is a foreground streaming process handle on the bash/process seam, not borrowing the public background-task API. + +**Expose bash raw spill paths to the model.** Rejected: a bash raw spill path contains raw `rg` stdout (`rg --json` records for grep), not the stable formatted search result. Search parses raw stdout only as an internal transport; model recovery uses a formatted result saved through `ctx.spillStore.saveText()`. + +**Add `spillStore.saveFile()` for bash output normalization first.** Rejected for this RFC's v1: `saveFile()` would help a future bash normalization pass move existing executor spill files into session-scoped spill storage, but search only needs bounded in-memory raw `rg` stdout before producing the model-facing artifact. `saveText()` is sufficient for the formatted search result. + +**Rely on the generic `dsh-spill-policy`.** Rejected: generic post-execute spill sees only the final tool result. If `grep` / `glob` return the first page inline, the generic policy cannot recover omitted results. The search tools must save the complete formatted result themselves before returning the bounded model-facing text. + +**Expose Claude Code's full `GrepTool` schema.** Rejected for v1: `output_mode`, context flags, multiline, `head_limit`, `offset`, `case_insensitive`, and type filters make the model-facing surface into a ripgrep wrapper. This harness keeps routine budgets and continuation mechanics in deployment policy and spill artifacts. + +**Keep early-stop search and skip formatted spill artifacts.** Rejected for this proposal: early stop is more efficient but gives the model no path to inspect later results. The chosen v1 optimizes result recoverability and implementation simplicity, with `timeoutMs`, `rawOutputMaxBytes`, bash backend caps, and formatted spill artifacts as safety backstops. + +**Expand the bash seam with a raw-output reader first.** Rejected: a portable `readRawOutput(ref, maxBytes)` API would add reference lifetime, permission, and backend storage semantics. A per-run `stdoutMaxBytes` request is the narrower seam: search either receives complete stdout within `rawOutputMaxBytes` or fails clearly. + +## Testing + +- Tests prove an aborted `exec.signal` reaches the bash backend (same-reference spec assertion plus the `SEARCH_ABORTED` result), and cover command construction/quoting (malicious patterns, paths with spaces, leading-dash values, quotes, newlines, glob metacharacters — unit assertions plus a real `bash -c` round-trip for every hostile value), `grep.path` as file and directory targets, `glob.path` as a directory search root, invalid pattern handling, no matches, malformed `rg --json` output, matched-line preview truncation, raw-output overflow, timeout/abort, formatted spill success/failure, the package-owned `SEARCH_*` error codes, and the no-background-task invariant. +- The first-party tool-owned spill precedent is covered directly: spill backend present, spill backend absent, `saveText()` failure, and missing spill owner. +- The package has real Loader-path coverage for the namespace plugin export shape (`name`, `inject`, `Config`, and `apply`, with no default export). +- A real-executor integration suite (`dsh-bash-local` + a real `rg`) verifies the world: hostile patterns stay inert, per-session cwd resolution, VCS-metadata exclusion, modification-time ordering, and real ripgrep stderr classification. It self-skips where `rg` is not on PATH (a CI accommodation mirroring the keyless e2e skip); the fake-executor suite alone carries the per-file 100% coverage gate. +- Snapshot gap note for the transcript-visible spill notice: this landed with the gap note, not a snapshot. The snapshot tier replays the acp-agent tree, and adding the search plugin there changes the assembled system prompt — every golden would need re-recording with a real key, which the implementing environment did not hold. The spill notice's exact transcript text is pinned by unit tests (`formatGlobOutput`/`formatGrepOutput` and the through-the-registry spill tests); wiring the plugin into the acp-agent tree plus a `test:snapshot:record` pass is the follow-up for the next key-holding session. + +## Consequences + +- `glob` and `grep` are model-facing tools in `@deepseek-ai/dsh-tool-fs-search`, not `ctx.fs` provider methods and not part of the existing `@deepseek-ai/dsh-tool-fs` root plugin. The package injects `tools`, `systemPrompt`, and `bash`; it does not inject `fs`, and `ctx.spillStore` stays optional via `ctx.get('spillStore')`. +- The schemas are exactly `glob(pattern, path?)` and `grep(pattern, path?, include?)`; search caps and timeout are defaulted, validated Config fields (`globMaxResults`, `grepMaxMatches`, `grepMaxLineBytes`, `rawOutputMaxBytes`, `timeoutMs`). +- The tools execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)`, forward `exec.signal`, never call `ctx.bash.start()`, and never expose a bash task id. The bash request workdir comes from `exec.agent?.session.header.cwd` when available; the resolved `spec.workdir` drives execution and relative-path display. +- The tools request `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam, parse only untruncated stdout within that cap, and treat over-cap or still-truncated raw output as a clear search failure; raw `rg` output is never exposed to the model. +- Oversized complete formatted results are saved through `ctx.spillStore.saveText()` when available while inline results stay bounded; spill failure, a missing backend, or a missing owner preserves the inline result and reports the unsaved remainder — never an `isError`. +- The package README, the generated config catalog, and exported JSDoc document the Config fields and `SEARCH_*` codes; the coding-agent example ships the tools (the acp-agent tree waits on the snapshot re-record above); the fs group README records the co-located bash/filesystem deployment requirement. + +## Risks + +Full-run `grep` can be slower than an early-stop search on broad patterns. The v1 accepts that cost for simpler implementation and complete-result recovery, bounded by tool timeout, bash timeout, `rawOutputMaxBytes`, and output caps. If this proves too slow, the direct-ripgrep or foreground-streaming alternatives remain available. + +Shell command construction is the sharpest safety edge. Because `ctx.bash` accepts a command string rather than an argv vector, the implementation must centralize shell quoting and test malicious patterns, paths with spaces, leading-dash patterns, quotes, newlines, and glob metacharacters. + +The v1 assumes a co-located bash/filesystem deployment. If bash searches one workspace and the `read` tool resolves paths against another, returned paths may not be follow-up-readable. The package documents this requirement but does not verify it at runtime. + +Spill locators are backend-owned. The current local backend returns local filesystem paths and works in deployments where `read`/`grep` can open those files; remote or workspace-confined deployments can use a backend whose locator and retrieval hint point at a supported retrieval mechanism. diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md index 7e13256669..ebc201f102 100644 --- a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md @@ -4,13 +4,13 @@ Status: implemented ## Problem -Session history exists in two places: current `SessionStore` objects and an optional persistence backend. Consumers that need exact inspection would otherwise duplicate live-versus-persisted precedence, persistence lifecycle handling, raw-event surface classification, and defensive cloning. Durable state can lag the live log between checkpoints, so persistence alone is not a truthful current source. +Session history exists in two places: current `SessionStore` objects and an optional persistence backend. Consumers that need exact inspection would otherwise duplicate live-versus-persisted precedence, persistence lifecycle handling, raw-event surface classification, relationship tracing, and defensive cloning. Durable state can lag the live log between checkpoints, so persistence alone is not a truthful current source. Full-text search is related but materially larger. Designing provider registration, extraction, synchronization, invalidation, ranking, and cursor contracts before a real backend exists creates two speculative state machines: one in the interface service and another in the eventual database package. ## Decision -`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-read service over one logical corpus. It exposes `listSessions()`, `listEvents(sessionId)`, and bounded `readEvent(request)`. It does not expose filters, lineage or provenance traversals, text extractors, search requests, provider registration, or derived-index synchronization. +`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-inspection service over one logical corpus. It exposes `listSessions()`, `listEvents(sessionId)`, bounded `readEvent(request)`, `traceSession(sessionId)`, and `traceEvent(request)`. It does not expose filters, text extractors, search requests, provider registration, or derived-index synchronization. The separate [tracing decision](2026-07-13-session-query-tracing.md) owns lineage and event-relationship semantics. The service observes the optional `ctx.sessionPersistence` binding dynamically but retains no persisted cache or invalidation listener. Each cross-corpus list asks the active backend for authoritative metadata, then overlays a fresh live-store list. Matching ids become one `SessionRecord`: the live header wins and `live`/`persisted` independently report source availability. Immutable header disagreement is `SESSION_QUERY_SOURCE_CONFLICT`. @@ -18,13 +18,13 @@ An exact target read first checks the live store and snapshots the live header a ## Surface semantics -`dsh-session` exports `foldSurface(events)`, and `SurfaceManager` uses the same transition functions for its incremental cache. The fold returns detached current nodes and each replacement's actual removed seqs. `listEvents()` uses that result to classify every raw event as `current`, `shadowed`, or `log-only`, so inspection cannot disagree with model-history derivation about positional replacement semantics. +`dsh-session` exports `foldSurface(events)`, and `SurfaceManager` uses the same transition functions for its incremental cache. The fold returns detached current nodes and each replacement's actual removed seqs. `listEvents()` and `traceEvent()` use that result to classify every raw event, so inspection cannot disagree with model-history derivation about positional replacement semantics. `readEvent()` returns the complete target plus raw neighbors by contiguous seq. `before` and `after` default to zero and are independently bounded by `readWindowMax`, default 50. The result carries a cloned `SessionHeader`, not a source-availability record, because determining a live target's persisted flag would violate the guarantee that live exact reads do not depend on persistence health. ## Security boundary -The service is context-wide trusted infrastructure, not an authorization layer. A future model-facing history tool or human UI applies explicit caller/session scope. This phase adds no model-facing tool and changes no transcript or snapshot surface. +The service is context-wide trusted infrastructure, not an authorization layer. A future model-facing history tool or human UI applies explicit caller/session scope. The service adds no model-facing tool and changes no transcript or snapshot surface. ## Alternatives considered @@ -32,10 +32,9 @@ The service is context-wide trusted infrastructure, not an authorization layer. - **Query only persistence** — rejected because checkpoints can lag the current live log. - **Cache persisted metadata and listen for writes/removals** — rejected because exact reads can ask the authoritative sources directly, while cache invalidation adds lifecycle and concurrency state before scale requires it. - **Define a provider-neutral search protocol now** — rejected because no provider consumes it. The first SQLite FTS package should own one reconciliation/transaction state machine; a smaller shared seam can be extracted later only when a second implementation proves the boundary. -- **Include lineage, provenance, and generic filters in phase one** — rejected because no current consumer requires them and canonical logs remain sufficient to add them with evidence later. ## Consequences -Phase one has one source-resolution state variable: the currently mounted persistence service. There are no provider queues, fingerprints, extractor registries, observation generations, or derived index updates. Exact reads remain usable in live-only deployments and deterministic when persistence is present. +The service has one source-resolution state variable: the currently mounted persistence service. There are no provider queues, fingerprints, extractor registries, observation generations, or derived index updates. Exact reads and event traces remain usable in live-only deployments and deterministic when persistence is present. -Cross-corpus listing and persisted exact reads perform backend I/O on each call. That is deliberate: correctness comes from current authoritative state, and scale-oriented search belongs to the phase-two database. Full-text search is unavailable until that package defines and implements its complete contract. +Cross-corpus listing, lineage tracing, and persisted event operations perform backend I/O on each call. That is deliberate: correctness comes from current authoritative state, and scale-oriented search belongs to the proposed database package. Full-text search is unavailable until that package defines and implements its complete contract. diff --git a/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md b/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md new file mode 100644 index 0000000000..f16f543ac5 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md @@ -0,0 +1,34 @@ +# RFC: Session query relationship tracing + +Status: implemented + +## Problem + +Session relationships are encoded across immutable headers, positional surface operations, and logged provenance arrays. A consumer reconstructing those relationships directly would need to duplicate corpus precedence, surface folding, malformed-log handling, deterministic lineage ordering, and cloning. Positional replacement and provenance are different graphs, so collapsing them into one generic edge type would also lose meaning. + +## Decision + +`ctx.sessionQuery` exposes `traceSession(sessionId)` and `traceEvent({ sessionId, seq })` alongside its exact reads. Both are one-shot views over the existing live-preferred corpus: session tracing consumes one complete corpus listing, while event tracing consumes one loaded logical log and one canonical surface fold. The service retains no lineage, reverse-index, or replacement state after a call. + +`SessionLineageTrace` returns the target, known parents in immediate-to-outward order, and recursive descendant trees whose siblings sort by creation time and then session id. `complete: true` carries the known root; `complete: false` carries the first unresolved parent id. A cycle connected to the target fails with `SESSION_QUERY_INVALID_LINEAGE`. + +`SessionEventTrace` keeps positional and provenance relationships separate. `replacedBy` is the immediate positional replacer, `replacementChain` follows replacers to the final node, and `replacedEventSeqs` lists the actual surface nodes directly removed by the target. `sourceEventSeqs` preserves direct logged source order, while `derivedEventSeqs` lists later direct reverse references in log order. Provenance is not expanded transitively. + +## Validation boundary + +Event tracing checks target existence before surface analysis. Both event listing and tracing then use `dsh-session`'s one-pass surface fold, which accepts or rejects the loaded log as a whole: event seqs are zero-based and contiguous, surface markers obey event-type eligibility, provenance belongs only to surface event types, present arrays are nonempty and duplicate-free, every source is an earlier seq, and every positional replacement names and cites all surface nodes it removes. Every contract failure uses `SESSION_QUERY_INVALID_SURFACE`; there is no weaker classification-only surface standard. + +All returned records and arrays are detached. A known live event trace never consults persistence; persisted event traces preserve the exact-read list/load consistency check. Session lineage is necessarily a cross-corpus operation and therefore preserves cross-corpus persistence failure semantics. + +## Alternatives considered + +- **Expose standalone tracing helpers** — rejected because the source-precedence and detachment boundary belongs to `ctx.sessionQuery`; public helpers would invite callers to bypass it. +- **Combine replacement and provenance edges** — rejected because a positional replacement can shadow surface nodes while also citing non-surface construction inputs, and consumers need to distinguish those meanings. +- **Return transitive provenance closure** — rejected because it obscures logged direct evidence, increases result size, and lets one malformed distant edge alter otherwise local output. +- **Best-effort traces over malformed provenance** — rejected because a structurally plausible partial graph would look authoritative. Exact inspection fails loudly when the canonical relationship contract is broken. + +## Consequences + +Consumers receive deterministic relationship views without a cache or second corpus. Event tracing performs whole-log validation and allocation on each call, while lineage tracing lists the complete logical corpus on each call. Those costs keep the source of truth explicit and are separate from the content-bearing full-text-search and filtering API. + +The feature has unit and service-level coverage but no snapshot or end-to-end fixture because it introduces no model-facing consumer, transcript change, or cross-process protocol. 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/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md index acfdf23bee..36216f74d8 100644 --- a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md +++ b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -The exact-read `ctx.sessionQuery` service deliberately has no derived index. Large persisted histories need full-text search without scanning every event on every query, while current live sessions need an overlay newer than the last durability checkpoint. Search also needs concrete ranking, snippets, filters, pagination, cancellation, and rebuild behavior. +The exact-read `ctx.sessionQuery` service deliberately has no derived index. Large persisted histories need full-text search without scanning every event on every query, while current live sessions need an overlay newer than the last durability checkpoint. Search also needs concrete ranking, snippets, pagination, cancellation, and rebuild behavior. Splitting those concerns across a speculative provider coordinator and a database implementation would create two coupled reconciliation state machines. The first real implementation should own the source observation, extraction, SQLite transaction, generation, and query as one lifecycle. @@ -20,7 +20,7 @@ Persisted documents survive restarts. Live overrides are connection-local and sh The implementation must define both cross-session and within-session scopes from executable use cases. Each searchable event is one document with session metadata, event metadata, surface classification, normalized semantic text, and a bounded plain-text snippet. Session results group by their strongest matching event; numeric backend scores remain private. -Filters compile to parameterized SQL before ranking. Query syntax is treated as data. Ordering includes stable tie fields. Opaque cursors bind to normalized request shape and the smallest relevant generation; unrelated session changes should not invalidate a within-session cursor. Cancellation must stop caller waiting and interrupt SQLite work where the runtime permits. +Search returns content-bearing result records rather than metadata-only headers. Chainable filters operate on that exact result shape and are designed and implemented with the search API instead of becoming a provider-specific pre-ranking contract. Query syntax is treated as data. Ordering includes stable tie fields. Opaque cursors bind to normalized request shape and the smallest relevant generation; unrelated session changes should not invalidate a within-session cursor. Cancellation must stop caller waiting and interrupt SQLite work where the runtime permits. Tokenizer choice remains an implementation experiment. FTS5 trigram supports substring recall but rejects useful terms shorter than three characters and increases index size; the proposal must benchmark that tradeoff against the default Unicode tokenizer before making it contract. @@ -41,7 +41,7 @@ Reconciliation may use stable fingerprints to avoid rewriting unchanged persiste - Restart tests cover unchanged, new, changed, and deleted persisted sessions without rebuilding the whole index. - Reopening preserves persisted rows and removes live rows; live rows shadow and then reveal their persisted base. -- Tests cover both search scopes, metadata filters, surface defaults, snippets, escaping, deterministic ties, pagination, scoped stale cursors, cancellation, dynamic persistence mount/unmount, and recovery after a failed transaction. +- Tests cover both search scopes, content-bearing results, chainable result filters, surface defaults, snippets, escaping, deterministic ties, pagination, scoped stale cursors, cancellation, dynamic persistence mount/unmount, and recovery after a failed transaction. - A schema mismatch resets only the derived database. - A keyless end-to-end test combines a real persistence backend with the real SQLite search package. - The RFC is amended to the measured tokenizer and public API actually implemented before moving to `implemented/`. diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 121bfd09f7..8c436f4cd6 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -20,6 +20,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | +| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | @@ -330,6 +331,64 @@ Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. +## `@deepseek-ai/dsh-tool-fs-search` + +### `glob` + +Find files whose paths match a glob pattern. Returns matching paths sorted by modification time, including hidden and ignored files (VCS metadata directories are excluded). Returns the first 100 paths inline; a capped result reports where the complete list was saved. + +```json +{ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\")." + }, + "path": { + "type": "string", + "description": "Directory to search in. Defaults to the session workspace; a relative path resolves against it." + } + }, + "required": [ + "pattern" + ] +} +``` + +Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-search/src/index.ts) + +### `grep` + +Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context. + +```json +{ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regular expression to search for (ripgrep syntax)." + }, + "path": { + "type": "string", + "description": "File or directory to search. Defaults to the session workspace; a relative path resolves against it." + }, + "include": { + "type": "string", + "description": "One glob filter for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). Not a list; negation is not supported." + } + }, + "required": [ + "pattern" + ] +} +``` + +Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-search/src/index.ts) + +glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. + ## `@deepseek-ai/dsh-tool-skill` ### `skill` 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/README.md b/examples/acp-agent/README.md index 35218f4753..5424be6ab3 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -7,7 +7,7 @@ pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) pnpm run demo:code-mode acp # the same server in Code Mode: one wire tool, run_code ``` -The leaf config loads the ACP app, DeepSeek adapter, sandboxed bash, approval and permission services, model-facing tools, and repeat guard. The app bundles the agent spine, JSONL persistence, and bridge, creates agents on `session/new`, and keeps stdout logger-free. [`fs.cordis.yml`](fs.cordis.yml) adds the unconfined in-process filesystem stack for its dedicated scenarios; [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK. See [Code Mode](../../packages/core/tools/README.md#code-mode). +The leaf config loads the ACP app, DeepSeek adapter, sandboxed bash, approval and permission services, model-facing tools, and repeat guard. The app bundles the agent spine, JSONL persistence, and bridge, creates agents on `session/new`, and keeps stdout logger-free. [`fs.cordis.yml`](fs.cordis.yml) adds the unconfined in-process filesystem stack and local tool-result spill storage for its dedicated scenarios; [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK. See [Code Mode](../../packages/core/tools/README.md#code-mode). ## stdout is the protocol 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/fs.cordis.snapshot.yml b/examples/acp-agent/fs.cordis.snapshot.yml index 53f2d677e2..939bf07398 100644 --- a/examples/acp-agent/fs.cordis.snapshot.yml +++ b/examples/acp-agent/fs.cordis.snapshot.yml @@ -17,5 +17,13 @@ name: '@deepseek-ai/dsh-fs-policy' - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' + - id: spill-local + name: '@deepseek-ai/dsh-spill-local' + config: + root: !!js process.env.DSH_SNAPSHOT_SPILL_ROOT ?? './.spill' + - id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 800 - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/fs.cordis.yml b/examples/acp-agent/fs.cordis.yml index b60c26f96d..52ca959a89 100644 --- a/examples/acp-agent/fs.cordis.yml +++ b/examples/acp-agent/fs.cordis.yml @@ -15,3 +15,11 @@ name: '@deepseek-ai/dsh-fs-policy' - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' + - id: spill-local + name: '@deepseek-ai/dsh-spill-local' + config: + root: !!js process.env.DSH_SNAPSHOT_SPILL_ROOT ?? './.spill' + - id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: !!js process.env.DSH_SNAPSHOT && 800 || 50000 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 3e605b3141..5c41f8c9fa 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)) @@ -51,6 +53,7 @@ const SCENARIOS: Scenario[] = [ // Its prompt and tool-schema sidecars pin the composed header. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, { name: 'tool-call-turn', hasModelTurn: true, recorded: true }, + { name: 'bash-spill', hasModelTurn: true, recorded: false, headerClass: 'fs', configPath: FS_CONFIG }, { name: 'fs-terminal-card', hasModelTurn: true, recorded: true }, { name: 'todo-plan', hasModelTurn: true, recorded: true }, { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' }, @@ -68,6 +71,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 +132,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/bash-spill/input.json b/examples/acp-agent/tests/snapshots/bash-spill/input.json new file mode 100644 index 0000000000..de9b769cf5 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/bash-spill/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the bash tool to print a large deterministic output, then reply DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl new file mode 100644 index 0000000000..b456f9c2f9 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -0,0 +1,23 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the bash tool to print a large deterministic output, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_spill","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} +{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-e194e47db58a/69c4a2d26b7e-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":21,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl new file mode 100644 index 0000000000..c06f71be92 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/bash-spill/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_spill","title":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","kind":"execute","status":"in_progress","rawInput":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","content":[{"type":"content","content":{"type":"text","text":"Print large deterministic output"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: {{spillLocator:bash.txt}}. Use read with offset/limit, or grep this path to search within it.)\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} 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/composition.md b/examples/coding-agent/composition.md index 90ff8222b9..52c81b5089 100644 --- a/examples/coding-agent/composition.md +++ b/examples/coding-agent/composition.md @@ -47,6 +47,14 @@ flowchart LR cfg --> plugin_coding_fs_policy plugin_coding_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] cfg --> plugin_coding_tool_fs + plugin_coding_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] + cfg --> plugin_coding_tool_fs_search + plugin_coding_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] + cfg --> plugin_coding_timeout_policy + plugin_coding_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] + cfg --> plugin_coding_spill_local + plugin_coding_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] + cfg --> plugin_coding_spill_policy ``` | Plugin id | Package / module | @@ -67,6 +75,10 @@ flowchart LR | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | | `tool-fs` | `@deepseek-ai/dsh-tool-fs` | +| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | +| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | +| `spill-local` | `@deepseek-ai/dsh-spill-local` | +| `spill-policy` | `@deepseek-ai/dsh-spill-policy` | Source config: [`examples/coding-agent/cordis.yml`](cordis.yml). diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 61e389ebc5..60d4293e0f 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. @@ -112,3 +114,29 @@ - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' + +# Bash-backed discovery tools (glob/grep): fixed ripgrep commands through the +# local bash executor above — not ctx.fs. Capped results save the complete +# formatted list through the spill backend below (ctx.spillStore, optional). +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + +# The tool-call timeout enforcer: arms each declared ToolDefinition.timeoutMs +# (the search tools above declare 30s) as a deadline on exec.signal. Without +# it a declared budget is advisory and only the bash executor's own timeout +# backstop applies. +- id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' + +# Tool-output spill stack: a local backend that saves oversized tool text under +# a private session-scoped dir, and the tools/post-execute policy that replaces +# an over-budget plain-text result with a preview + the spill locator/retrieval +# hint. A leaf pair after the app (needs ctx.tools). The policy is a no-op until +# a tool returns more than maxInlineBytes of plain text. +- id: spill-local + name: '@deepseek-ai/dsh-spill-local' + +- id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 50000 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..1caef3861b 100644 --- a/knip.json +++ b/knip.json @@ -40,6 +40,11 @@ "project": ["src/**/*.ts", "tests/**/*.ts"], "ignoreDependencies": ["cordis"] }, + "packages/util/retention": { + "entry": ["tests/**/*.spec.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"], + "ignoreDependencies": ["cordis"] + }, "packages/support/acp-snapshot": { "entry": ["tests/**/*.spec.ts", "tests/fixtures/fake-acp-agent.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"], @@ -66,6 +71,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"] @@ -123,6 +137,11 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/fs/tool-fs-search": { + "entry": ["tests/**/*.spec.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"], + "ignoreBinaries": ["rg"] + }, "packages/mcp/mcp-client": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"], diff --git a/packages/README.md b/packages/README.md index a3e0b1ac0a..93a8a849d7 100644 --- a/packages/README.md +++ b/packages/README.md @@ -13,32 +13,33 @@ Packages live at `packages///`; groups are containers, while names r | [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface | | [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface | -| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface | +| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, the model-facing file tools, and the bash-backed discovery tools | Product — stable surface | | [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface | | [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface | -| [`context/`](context/README.md) | 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 | | [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | -| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface | +| [`spill/`](spill/README.md) | Spill capability family: the storage seam, a local impl, and the tool-result spill policy | Product — stable surface | | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface | +| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface | | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface | | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | -| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface | +| [`session-query/`](session-query/README.md) | Session retrieval: logical corpus, bounded reads, lineage, and event relationships | Product — stable surface | | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface | | [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/ACP/JSON-RPC bins) the leaves load | Support — example infra | -| [`support/`](support/README.md) | Support infrastructure (testkits, 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 (testkits, 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, timeout, retention) | Support — small, stable, harness-dep-free | Groups distinguish product API from support infrastructure. New packages join an existing group; a new group updates its README and this table. ## Dependencies -The inter-package dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI). +The dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI). The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-spine-demo`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)). diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 6048f886a3..4ff043457c 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -23,7 +23,7 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. - **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. -- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file. +- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file. - **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). - **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 9c2b0b4511..b988631b0b 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -92,10 +92,13 @@ export class LocalBashExecutor extends BashExecutor { this.config.maxTimeoutMs, 'bash-local: request.timeoutMs', ) + const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes + assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes) return { command: request.command, workdir: request.workdir ?? this.config.cwd ?? process.cwd(), timeoutMs, + stdoutMaxBytes, ...request.signal ? { signal: request.signal } : {}, // Explicit environment values are merged after credential scrubbing in run.ts. ...request.stdin !== undefined ? { stdin: request.stdin } : {}, @@ -111,7 +114,8 @@ export class LocalBashExecutor extends BashExecutor { const outcome = await runBash({ command: spec.command, cwd: spec.workdir, - maxOutputBytes: this.config.maxOutputBytes, + stdoutMaxBytes: spec.stdoutMaxBytes, + stderrMaxBytes: this.config.maxOutputBytes, graceMs: this.config.graceMs, signal: d.signal, stdin: spec.stdin, @@ -128,7 +132,8 @@ export class LocalBashExecutor extends BashExecutor { const running = runBash({ command: spec.command, cwd: spec.workdir, - maxOutputBytes: this.config.maxOutputBytes, + stdoutMaxBytes: this.config.maxOutputBytes, + stderrMaxBytes: this.config.maxOutputBytes, graceMs: this.config.graceMs, signal: spec.signal, stdin: spec.stdin, diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index 02e7a963be..5b9c49daf3 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -52,8 +52,10 @@ export function childEnv(extra?: Record): NodeJS.ProcessEnv { export interface SpawnSpec { command: string cwd: string - /** Per-stream in-memory cap; overflow spills to disk (tail kept in memory). */ - maxOutputBytes: number + /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */ + stdoutMaxBytes: number + /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */ + stderrMaxBytes: number /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */ graceMs: number /** @@ -283,8 +285,8 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB ? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }) : spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) - const stdout = new OutputCollector(spec.maxOutputBytes, 'stdout', spillDir) - const stderr = new OutputCollector(spec.maxOutputBytes, 'stderr', spillDir) + const stdout = new OutputCollector(spec.stdoutMaxBytes, 'stdout', spillDir) + const stderr = new OutputCollector(spec.stderrMaxBytes, 'stderr', spillDir) child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) }) child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) }) diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index b176cbaf86..9aca00490c 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -71,6 +71,23 @@ describe('LocalBashExecutor.run', () => { const { bash } = await setup() expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/) expect(() => bash.resolve({ command: 'true', timeoutMs: -1 })).toThrow(/request\.timeoutMs/) + expect(() => bash.resolve({ command: 'true', stdoutMaxBytes: Number.NaN })).toThrow(/request\.stdoutMaxBytes/) + expect(() => bash.resolve({ command: 'true', stdoutMaxBytes: -1 })).toThrow(/request\.stdoutMaxBytes/) + }) + + it('defaults stdoutMaxBytes to maxOutputBytes and lets foreground callers raise stdout only', async () => { + const { bash } = await setup({ maxOutputBytes: 100 }) + expect(bash.resolve({ command: 'true' }).stdoutMaxBytes).toBe(100) + + const result = await bash.run(bash.resolve({ + command: 'printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', + stdoutMaxBytes: 500, + })) + + expect(result.stdout.truncated).toBe(false) + expect(result.stdout.text).toBe('x'.repeat(500)) + expect(result.stderr.truncated).toBe(true) + expect(result.stderr.text.length).toBeLessThanOrEqual(100) }) it('per-call timeout takes precedence under the cap and kills on expiry', async () => { diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index 923b3adf7b..02668ada3e 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/bash/bash-local/tests/run.spec.ts @@ -26,7 +26,8 @@ function spec(command: string, overrides: Partial[0]> return { command, cwd: process.cwd(), - maxOutputBytes: 64_000, + stdoutMaxBytes: 64_000, + stderrMaxBytes: 64_000, graceMs: 3_000, ...overrides, } @@ -224,10 +225,24 @@ describe('stdin and extra env (set by in-process plugins)', () => { }) describe('output truncation and spill', () => { + it('applies stdout and stderr caps independently', async () => { + const result = await runBash( + spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', { + stdoutMaxBytes: 500, + stderrMaxBytes: 100, + }), + { spillDir }, + ).done + expect(result.stdout.truncated).toBe(false) + expect(result.stdout.text).toBe('x'.repeat(500)) + expect(result.stderr.truncated).toBe(true) + expect(result.stderr.text.length).toBeLessThanOrEqual(100) + }) + it('keeps the tail and spills the full stream to disk', async () => { // 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail. const result = await runBash( - spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }), + spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, ).done expect(result.stdout.truncated).toBe(true) @@ -242,7 +257,7 @@ describe('output truncation and spill', () => { it('does not truncate output exactly at the cap', async () => { const result = await runBash( - spec('printf "%.0sx" $(seq 1 500)', { maxOutputBytes: 500 }), + spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, ).done expect(result.stdout.truncated).toBe(false) @@ -253,7 +268,7 @@ describe('output truncation and spill', () => { it('settles with the tail and no spill path when final spill close fails', async () => { failNextClose.value = true const result = await runBash( - spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }), + spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, ).done expect(failNextClose.value).toBe(false) @@ -364,7 +379,7 @@ describe('environment and spill-file hardening', () => { it('creates spill files with owner-only permissions and random names', async () => { const result = await runBash( - spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }), + spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, ).done const path = result.stdout.spillPath! @@ -375,7 +390,7 @@ describe('environment and spill-file hardening', () => { it('defaults spills into a private per-process directory', async () => { const result = await runBash( - spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }), + spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), ).done const dir = dirname(result.stdout.spillPath!) expect(dir).toMatch(/dsh-bash-/) diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index ba0915c929..ff2dfe0129 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -27,7 +27,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp ## Vocabulary -`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, sandboxMode) before execution. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing. +`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, sandboxMode) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing. The seam also owns the per-session mode override vocabulary: the log-only `'bash/sandbox-mode'` session event, the pure `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path. `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index d9ad6eda6d..81b051ceb3 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -34,6 +34,13 @@ export interface BashExecRequest { workdir?: string | undefined /** Timeout override in milliseconds (implementations cap it). */ timeoutMs?: number | undefined + /** + * Foreground stdout capture budget in bytes. Absent uses the executor's + * default output cap. Trusted in-process consumers use this when they must + * parse complete stdout up to their own bounded limit; the model-facing bash + * tool does not expose it as a parameter. + */ + stdoutMaxBytes?: number | undefined /** Abort signal — implementations kill the command when it fires. */ signal?: AbortSignal | undefined /** @@ -67,6 +74,11 @@ export interface BashExecSpec { command: string workdir: string timeoutMs: number + /** + * Resolved foreground stdout capture budget in bytes. `run()` uses it for + * stdout; background tasks and stderr keep the executor's own output cap. + */ + stdoutMaxBytes: number /** Abort signal — implementations kill the command when it fires. */ signal?: AbortSignal | undefined /** Bytes to write to stdin before closing it; absent means no stdin. */ @@ -96,9 +108,19 @@ export interface BashRunResult { exitCode: number | null /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ signal: NodeJS.Signals | null - /** True when the executor's own timeout killed the command. */ + /** + * True when the executor's own timeout was the FIRST cause to cut the command + * short. Mutually exclusive with {@link aborted}: one fused deadline drives + * both the timeout and the caller's cancellation, so a timeout and an abort + * racing before process close report the single first-abort cause, not both + * (see the [timeout-library RFC](../../../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md)). + */ timedOut: boolean - /** True when the caller's AbortSignal killed the command. */ + /** + * True when the caller's `AbortSignal` was the FIRST cause to kill the command + * (and it was not the executor's own timeout). Mutually exclusive with + * {@link timedOut} — see there for the first-cause classification. + */ aborted: boolean /** The effective timeout applied to this run (after defaulting/capping). */ timeoutMs: number diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts index a869f4834c..63d9533410 100644 --- a/packages/bash/bash/tests/service.spec.ts +++ b/packages/bash/bash/tests/service.spec.ts @@ -15,6 +15,7 @@ class StubExecutor extends BashExecutor { command: request.command, workdir: request.workdir ?? '/stub', timeoutMs: request.timeoutMs ?? 1000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, ...request.signal ? { signal: request.signal } : {}, sandboxMode: request.sandboxMode, } @@ -54,7 +55,7 @@ describe('BashExecutor service seam', () => { const ctx = new Context() await ctx.plugin(StubExecutor) const spec = ctx.bash.resolve({ command: 'echo hi' }) - expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000, sandboxMode: undefined }) + expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000, stdoutMaxBytes: 64_000, sandboxMode: undefined }) const result = await ctx.bash.run(spec) expect(result.exitCode).toBe(0) diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index f2a68e4bd9..c20a30c98e 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -34,7 +34,7 @@ The tool owns its `presentCall`/`presentResult` render intent. A foreground call ## The tool builds its request from named args only -The `BashExecRequest` seam carries optional `stdin` and `env`, used by trusted in-process plugins. This tool does **not** expose or forward them: it builds requests from named command/workdir/timeout/signal/sandbox fields only. This is not a trust boundary; the local executor's ambient credential scrub is the security control. +The `BashExecRequest` seam carries optional `stdoutMaxBytes`, `stdin`, and `env`, used by trusted in-process plugins. This tool does **not** expose or forward them: it builds requests from named command/workdir/timeout/signal/sandbox fields only. This is not a trust boundary; the local executor's ambient credential scrub is the security control. ## Permissions and escalation diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index c09c14c459..5ef710779d 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -101,6 +101,7 @@ class RecordingSandboxExecutor extends BashExecutor { return { command: request.command, workdir: request.workdir ?? process.cwd(), + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, timeoutMs: request.timeoutMs ?? 1000, ...request.signal ? { signal: request.signal } : {}, sandboxMode: request.sandboxMode ?? 'read-only', @@ -140,7 +141,13 @@ class CountingStartExecutor extends BashExecutor { starts = 0 resolve(request: BashExecRequest): BashExecSpec { - return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0, sandboxMode: request.sandboxMode } + return { + command: request.command, + workdir: request.workdir ?? '/x', + timeoutMs: request.timeoutMs ?? 0, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + sandboxMode: request.sandboxMode, + } } run(): Promise { return Promise.reject(new Error('unused')) } @@ -927,11 +934,12 @@ describe('the model-facing bash tool builds its request from named args only (no /** * Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a * test can assert what the model-facing tool DID and DID NOT forward. The `bash` - * tool does not expose `stdin`/`env` as parameters (bash syntax already gives a - * model that power), so it must build its request from named args only and + * tool does not expose trusted-plugin fields (`stdoutMaxBytes`, `stdin`, or + * `env`) as parameters, so it must build its request from named args only and * never spread unknown tool-call keys into it. This guard's job is to catch a * future refactor that blindly forwards `...args` — which would silently thread - * model input into the post-scrub `env` merge — NOT to defend a trust boundary + * model input into the post-scrub `env` merge or per-run capture budget — NOT + * to defend a trust boundary * (the credential scrub in dsh-bash-local is the security control; see the * bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()` * hands back an already-settled fake handle so the task registration completes. @@ -944,6 +952,7 @@ describe('the model-facing bash tool builds its request from named args only (no command: request.command, workdir: request.workdir ?? process.cwd(), timeoutMs: request.timeoutMs ?? 0, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, ...request.signal ? { signal: request.signal } : {}, ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, @@ -980,7 +989,7 @@ describe('the model-facing bash tool builds its request from named args only (no return { ctx, bash: ctx.bash as RecordingBashExecutor } } - it('does not forward env/stdin even when the model includes them as extra arguments', async () => { + it('does not forward trusted-only fields even when the model includes them as extra arguments', async () => { const { ctx, bash } = await setupRecording() // Unknown `env` and `stdin` keys are ignored by the schema and named request construction. // This preserves the request shape; it is not a security boundary because shell syntax can @@ -993,6 +1002,7 @@ describe('the model-facing bash tool builds its request from named args only (no description: 'echo', env: { SNEAKY_API_KEY: 'leak' }, stdin: 'malicious payload', + stdoutMaxBytes: 999_999, }, }) expect(bash.requests).toHaveLength(1) @@ -1000,9 +1010,10 @@ describe('the model-facing bash tool builds its request from named args only (no expect(request.command).toBe('echo hi') expect('env' in request).toBe(false) expect('stdin' in request).toBe(false) + expect('stdoutMaxBytes' in request).toBe(false) }) - it('a background bash call likewise carries no env/stdin', async () => { + it('a background bash call likewise carries no trusted-only fields', async () => { const { ctx, bash } = await setupRecording() const result = await ctx.tools.execute({ callId: CallId('no-forward-2'), @@ -1013,6 +1024,7 @@ describe('the model-facing bash tool builds its request from named args only (no run_in_background: true, env: { TOKEN: 'leak' }, stdin: 'x', + stdoutMaxBytes: 999_999, }, }) // The call really went down the background path (the recorder sees the real @@ -1024,5 +1036,6 @@ describe('the model-facing bash tool builds its request from named args only (no expect(request.command).toBe('sleep 1') expect('env' in request).toBe(false) expect('stdin' in request).toBe(false) + expect('stdoutMaxBytes' in request).toBe(false) }) }) 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..0132a64eb8 --- /dev/null +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -0,0 +1,2888 @@ +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 }, + sourceEventSeqs: [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..9fb6383459 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', @@ -157,10 +158,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'sessionQuery', - summary: 'Live-preferred logical-corpus and exact-event read service.', + summary: 'Live-preferred logical-corpus exact-read and relationship-tracing service.', methods: [ 'listSessions(): Promise', 'async listEvents(sessionId: SessionId): Promise', + 'async traceSession(sessionId: SessionId): Promise', + 'async traceEvent(request: SessionEventTraceRequest): Promise', 'async readEvent(request: SessionEventReadRequest): Promise', ], }, @@ -188,6 +191,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'async get(name: string, options: SkillLookupOptions = {}): Promise', ], }, + { + key: 'spillStore', + summary: 'Abstract spill storage service.', + methods: [ + 'abstract saveText(input: SaveTextSpill): Promise', + ], + }, { key: 'subagents', summary: 'Named provider registry and capability-checked start surface.', @@ -503,7 +513,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', @@ -567,11 +577,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BashExecRequest', - declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n sandboxMode?: SandboxMode | undefined;\n}', + declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n sandboxMode?: SandboxMode | undefined;\n}', }, { name: 'BashExecSpec', - declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n sandboxMode: SandboxMode | undefined;\n}', + declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n sandboxMode: SandboxMode | undefined;\n}', }, { name: 'BashProcess', @@ -649,6 +659,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 +711,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 +749,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', @@ -781,6 +807,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SandboxPolicy', declaration: 'export interface SandboxPolicy {\n mode: ConfinedSandboxMode;\n workspaceRoot: string;\n}', }, + { + name: 'SaveTextSpill', + declaration: 'export interface SaveTextSpill {\n owner: SpillOwner;\n source: SpillSource;\n suggestedName: string;\n content: string;\n}', + }, { name: 'ScopeKey', declaration: 'export type ScopeKey = object;', @@ -795,7 +825,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', @@ -809,6 +839,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventSurface', declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';', }, + { + name: 'SessionEventTrace', + declaration: 'export interface SessionEventTrace {\n target: SessionEventRecord;\n replacedBy?: number;\n replacementChain: number[];\n replacedEventSeqs: number[];\n sourceEventSeqs: number[];\n derivedEventSeqs: number[];\n}', + }, + { + name: 'SessionEventTraceRequest', + declaration: 'export interface SessionEventTraceRequest {\n sessionId: SessionId;\n seq: number;\n}', + }, { name: 'SessionEventType', declaration: 'export type SessionEventType = keyof SessionEventMap;', @@ -829,6 +867,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionId', declaration: 'export type SessionId = Branded<\'SessionId\'>;', }, + { + name: 'SessionLineageNode', + declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n descendants: SessionLineageNode[];\n}', + }, + { + name: 'SessionLineageTrace', + declaration: 'export type SessionLineageTrace = {\n target: SessionRecord;\n ancestors: SessionRecord[];\n descendants: SessionLineageNode[];\n} & ({\n complete: true;\n root: SessionRecord;\n} | {\n complete: false;\n unresolvedParentId: SessionId;\n});', + }, { name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', @@ -865,6 +911,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SkillSummary', declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly disableModelInvocation?: boolean;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}', }, + { + name: 'SpillLocator', + declaration: 'export type SpillLocator = Branded<\'SpillLocator\'>;', + }, + { + name: 'SpillOwner', + declaration: 'export interface SpillOwner {\n sessionId: SessionId;\n}', + }, + { + name: 'SpillRef', + declaration: 'export interface SpillRef {\n locator: SpillLocator;\n bytes: number;\n retrievalHint: string;\n}', + }, + { + name: 'SpillSource', + declaration: 'export interface SpillSource {\n toolName: string;\n callId: CallId;\n label: string;\n}', + }, { name: 'StreamChunk', declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n};', @@ -969,10 +1031,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 +1049,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 +1069,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 +1099,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 15cc0aa410..d68c140883 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -234,10 +234,15 @@ async function runTurn( // `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them. const content = decision.content ?? message.content session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' }) - // `allow.additionalContext` is a SEPARATE context/message the next request - // also sees. The turn is open, so inject() appends it into THIS turn. - if (decision.additionalContext) { - agent.inject(decision.additionalContext.content, { source: decision.additionalContext.source }) + // Every `allow.additionalContexts` entry is a separate context/message the + // next request also sees. The turn is open, so inject() appends each one + // into THIS turn without flattening provenance, framing, or metadata. + for (const context of decision.additionalContexts ?? []) { + agent.inject(context.content, { + source: context.source, + ...context.envelope !== undefined ? { envelope: context.envelope } : {}, + ...context.meta !== undefined ? { meta: context.meta } : {}, + }) } } @@ -587,7 +592,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 @@ -597,7 +602,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 4b37210993..00f40c99cd 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 fb686928b1..32ff3930bd 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 5639f30daf..ca031995ef 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 a message; starts a turn when idle. 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 3aad65a70e..1c9f678ea5 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 + * Prompt interception result. `allow.content` replaces the prompt and each + * `additionalContexts` entry becomes a separate context message. `block` records a * durable `prompt/blocked`; an all-blocked batch ends a zero-step rejected turn. */ 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 queued and steering work, including work waiting to start, and abort diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 7e201ab647..2d28b28a3d 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -32,7 +32,7 @@ The store pairs announced creation with disposal, publishes post-commit append n Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. -- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs. +- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs. - `session.deriveMessages()` incrementally projects each new surface node once and returns a fresh array over shared frozen messages. A surface rewrite rebuilds the projection; there is no raw-log fallback. - `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants. - `session.surface` lazily folds only new `surfaceOp` markers; `replaceGeneration` changes on every rewrite. @@ -49,13 +49,15 @@ Durable values need one accepted representation, not a check followed by a secon - `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them. - `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types. - `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list. -- `foldSurface(events)` — replay the canonical surface transitions into detached current nodes and actual replacement ranges, rejecting surface-eligible events that lack their mandatory marker. `SurfaceManager` shares the same transitions while retaining its incremental cache. +- `foldSurface(events)` — replay the one canonical surface contract into detached current nodes and actual replacement ranges. The same pass rejects non-contiguous event seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface node; `SurfaceManager` shares the atomic transition while retaining its incremental cache. - `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log. ### Request-header reconstruction (`request-header.ts`) `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..9b3312c5e9 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -13,9 +13,9 @@ 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 { SurfaceManager } from './surface.ts' import { foldRequestHeader } from './request-header.ts' export * from './types.ts' @@ -131,43 +131,6 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe return deepFreeze(record as unknown as SessionHeader) } -/** Validate the runtime shape of surface metadata after its JSON snapshot. */ -function assertSurfaceMetadataShape( - type: string, - surfaceOp: unknown, - sourceEventSeqs: unknown, -): void { - const eligible = isSurfaceEligibleType(type) - if (!eligible) { - if (surfaceOp !== undefined || sourceEventSeqs !== undefined) { - throw new Error(`session event "${type}" is not surface-eligible and cannot carry surface metadata`) - } - return - } - if (surfaceOp === undefined) { - throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`) - } - if (surfaceOp !== 'append') { - if (surfaceOp === null || typeof surfaceOp !== 'object' || Array.isArray(surfaceOp)) { - throw new Error(`session event "${type}" carries an invalid surfaceOp`) - } - const op = surfaceOp as Record - const keys = Object.keys(op) - if (keys.length !== 3 || !Object.hasOwn(op, 'op') || !Object.hasOwn(op, 'start') || !Object.hasOwn(op, 'end') - || op['op'] !== 'replace' - || typeof op['start'] !== 'number' || !Number.isSafeInteger(op['start']) || op['start'] < 0 - || typeof op['end'] !== 'number' || !Number.isSafeInteger(op['end']) || op['end'] < 0) { - throw new Error(`session event "${type}" carries an invalid replace surfaceOp`) - } - } - if (sourceEventSeqs !== undefined) { - if (!Array.isArray(sourceEventSeqs) - || sourceEventSeqs.some(seq => typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0)) { - throw new Error(`session event "${type}" sourceEventSeqs must contain non-negative safe integers`) - } - } -} - /** Validate the fixed event envelope after one-pass JSON materialization. */ function assertSessionEventEnvelope(value: Record, index: number): asserts value is SessionEvent { const event = value @@ -226,6 +189,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. * @@ -234,13 +213,15 @@ const attachments = new WeakMap() */ export class Session { private log: SessionEvent[] = [] + /** Incremental acceptance state, kept separate from the public lazy view. */ + private readonly surfaceValidator = new SurfaceManager(this.log) /** * Derived surface — a cached linked list of message-producing events. * Lazily rebuilt from `surfaceOp` markers in the log; processes only new * events (delta) on each access — the log is append-only, so prior events * never change. - * `append`. Undefined until first accessed (including after fork/seed). + * Undefined until first accessed (including after fork/seed). */ private _surface: SurfaceManager | undefined @@ -269,7 +250,7 @@ export class Session { // `seq = log.length` contract the whole system relies on). Without this, // a bad seed would surface only later as a backend rejection or a silent // divergence between the live log and disk. - this.log = Array.from(seed, (source, index) => { + for (const [index, source] of seed.entries()) { // The seed is a persistence/replay boundary: validate and detach the // complete event in one lossless-JSON pass. const snapshot = snapshotJsonValue(source) @@ -280,20 +261,16 @@ export class Session { if (snapshot.seq !== index) { throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`) } - // Surface-eligible events MUST carry a surfaceOp marker — the surface is - // the sole source of derived history, so a marker-less message event - // would load fine yet vanish from deriveMessages(). `append` enforces - // this at compile time via its typed overload; a seed arrives as raw - // SessionEvent[] (replay/fork/load), bypassing that, so re-check at - // runtime here rather than silently resuming with empty history. - const structural = snapshot as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown } + // A seed is accepted incrementally through the same transition as a + // live append and a full-log fold. The candidate is planned before it + // enters `log`, so a failure cannot partially mutate the surface. try { - assertSurfaceMetadataShape(snapshot.type, structural.surfaceOp, structural.sourceEventSeqs) + this.surfaceValidator.validateNext(snapshot) } catch (error: unknown) { throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`) } - return deepFreeze(snapshot) - }) + this.log.push(deepFreeze(snapshot)) + } } this.header = snapshotSessionHeader(id, header) } @@ -340,7 +317,10 @@ export class Session { * @throws if `data` or surface metadata is not losslessly JSON-serializable * (BigInt, function, symbol, undefined, negative zero, non-finite number, * circular reference, sparse array, or an exotic object such as - * Map/Set/Date/class instance). One recursive pass reads, validates, and + * Map/Set/Date/class instance), or when the candidate violates the + * canonical surface contract (marker shape and eligibility, unique + * earlier provenance, positional replacement validity, and complete + * shadowed-node coverage). One recursive pass reads, validates, and * copies each nested value once, so a stateful getter cannot supply one value * to validation and another to storage. The event log is the durable source * of truth, so a bad event fails at the append site rather than later during @@ -366,25 +346,21 @@ export class Session { if (surfaceMetadataSnapshot === undefined) { throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`) } - assertSurfaceMetadataShape( - type, - (surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp, - (surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs, - ) - const entry = attachments.get(this) if (entry?.appending) { throw new Error('session append cannot reenter while another append is being published') } + const event = deepFreeze({ + type, + seq: this.log.length, + time: Date.now(), + data: dataSnapshot, + ...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }), + } as unknown as SessionEvent) + this.surfaceValidator.validateNext(event as SessionEvent) + if (entry !== undefined) entry.appending = true try { - const event = deepFreeze({ - type, - seq: this.log.length, - time: Date.now(), - data: dataSnapshot, - ...surfaceMetadataSnapshot, - } as unknown as SessionEvent) let callbacks: SessionCallback[] | undefined const callbackArgs: unknown[] = [this, event] if (entry !== undefined) { @@ -514,8 +490,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/surface.ts b/packages/core/session/src/surface.ts index 37eebbf7c3..1662d2d80d 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -85,6 +85,18 @@ interface SurfaceFoldState { replaceGeneration: number } +/** A validated replacement transition that has not mutated fold state yet. */ +interface SurfaceReplacePlan extends SurfaceFoldReplacement { + kind: 'replace' + startIdx: number + endIdx: number +} + +/** One validated surface transition that has not mutated fold state yet. */ +type SurfacePlan = + | { kind: 'append'; seq: number } + | SurfaceReplacePlan + /** Create an empty surface fold state. */ function createFoldState(replaceGeneration = 0): SurfaceFoldState { return { @@ -94,39 +106,89 @@ function createFoldState(replaceGeneration = 0): SurfaceFoldState { } } -/** Apply one event and return replacement metadata only when one occurred. */ -function applySurfaceEvent( - state: SurfaceFoldState, - event: SessionEvent, -): SurfaceFoldReplacement | undefined { - if (!isSurfaceEligibleType(event.type)) return - if (!isSurfaceEvent(event)) { - throw new Error(`surface event "${event.type}" (seq ${event.seq}) carries no surfaceOp marker`) - } +/** Whether a runtime value is a non-negative safe event sequence. */ +function isEventSeq(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +} - if (event.surfaceOp === 'append') { - const tail = state.nodes.length > 0 ? state.nodes[state.nodes.length - 1] : undefined - const node: SurfaceNode = { seq: event.seq, prev: tail?.seq ?? null, next: null } - if (tail) tail.next = event.seq - state.nodes.push(node) - state.nodeBySeq.set(event.seq, node) +/** Whether a runtime value is the exact positional-replacement shape. */ +function isReplaceOp(value: object): value is Extract { + const op = value as Record + return Object.keys(op).length === 3 + && Object.hasOwn(op, 'op') + && Object.hasOwn(op, 'start') + && Object.hasOwn(op, 'end') + && op['op'] === 'replace' + && isEventSeq(op['start']) + && isEventSeq(op['end']) +} + +/** Validate event-local surface eligibility and return its operation. */ +function surfaceOpOf(event: SessionEvent): SurfaceOp | undefined { + const raw = event as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown } + if (!isSurfaceEligibleType(event.type)) { + if (raw.surfaceOp !== undefined) { + throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`) + } + if (raw.sourceEventSeqs !== undefined) { + throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry sourceEventSeqs`) + } return } + const op = raw.surfaceOp + if (op === undefined) { + throw new Error(`session event "${event.type}" is surface-eligible and requires a surfaceOp marker`) + } + if (op === 'append') return op + if (op === null || typeof op !== 'object' || Array.isArray(op)) { + throw new Error(`session event "${event.type}" carries an invalid surfaceOp`) + } + if (!isReplaceOp(op)) { + throw new Error(`session event "${event.type}" carries an invalid replace surfaceOp`) + } + return op +} - return { - seq: event.seq, - start: event.surfaceOp.start, - end: event.surfaceOp.end, - shadowedSeqs: replaceSurface(state, event.seq, event.surfaceOp), +/** Validate provenance against prior log entries and the replacement range. */ +function assertProvenance( + event: SessionEvent, + shadowedSeqs: readonly number[], +): void { + const raw = (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs + const sources = new Set() + if (raw !== undefined) { + if (!Array.isArray(raw)) { + throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`) + } + if (raw.length === 0) { + throw new Error('sourceEventSeqs must not be empty when present') + } + let nonEarlierSource: number | undefined + for (const source of raw) { + if (!isEventSeq(source)) { + throw new Error(`session event "${event.type}" sourceEventSeqs must densely contain non-negative safe integers`) + } + sources.add(source) + if (nonEarlierSource === undefined && source >= event.seq) nonEarlierSource = source + } + if (sources.size !== raw.length) { + throw new Error('sourceEventSeqs must not contain duplicates') + } + if (nonEarlierSource !== undefined) { + throw new Error(`sourceEventSeqs must reference earlier events: ${nonEarlierSource} >= current seq ${event.seq}`) + } + } + const missing = shadowedSeqs.filter(seq => !sources.has(seq)) + if (missing.length > 0) { + throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`) } } -/** Apply one positional replacement and return the nodes it removed. */ -function replaceSurface( +/** Locate one replacement range without mutating the current fold state. */ +function replacementRange( state: SurfaceFoldState, - newSeq: number, op: Extract, -): number[] { +): Pick { const startNode = state.nodeBySeq.get(op.start) if (!startNode) { throw new Error(`surface replace: start seq ${op.start} not found in surface`) @@ -140,6 +202,42 @@ function replaceSurface( if (startIdx > endIdx) { throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`) } + return { + startIdx, + endIdx, + shadowedSeqs: state.nodes.slice(startIdx, endIdx + 1).map(node => node.seq), + } +} + +/** Validate one event at its replay boundary and prepare its atomic fold transition. */ +function planSurfaceEvent( + state: SurfaceFoldState, + event: SessionEvent, + expectedSeq: number, +): SurfacePlan | undefined { + if (event.seq !== expectedSeq) { + throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`) + } + const surfaceOp = surfaceOpOf(event) + if (surfaceOp === undefined) return + if (surfaceOp === 'append') { + assertProvenance(event, []) + return { kind: 'append', seq: event.seq } + } + const range = replacementRange(state, surfaceOp) + assertProvenance(event, range.shadowedSeqs) + return { + kind: 'replace', + seq: event.seq, + start: surfaceOp.start, + end: surfaceOp.end, + ...range, + } +} + +/** Apply one already-validated positional replacement. */ +function replaceSurface(state: SurfaceFoldState, plan: SurfaceReplacePlan): void { + const { startIdx, endIdx } = plan const removed = state.nodes.splice(startIdx, endIdx - startIdx + 1) for (const node of removed) state.nodeBySeq.delete(node.seq) @@ -147,16 +245,40 @@ function replaceSurface( const prevNode = startIdx > 0 ? state.nodes[startIdx - 1] : undefined const nextNode = startIdx < state.nodes.length ? state.nodes[startIdx] : undefined const newNode: SurfaceNode = { - seq: newSeq, + seq: plan.seq, prev: prevNode?.seq ?? null, next: nextNode?.seq ?? null, } - if (prevNode) prevNode.next = newSeq - if (nextNode) nextNode.prev = newSeq + if (prevNode) prevNode.next = plan.seq + if (nextNode) nextNode.prev = plan.seq state.nodes.splice(startIdx, 0, newNode) - state.nodeBySeq.set(newSeq, newNode) + state.nodeBySeq.set(plan.seq, newNode) state.replaceGeneration += 1 - return removed.map(node => node.seq) +} + +/** Apply one event and return replacement metadata only when one occurred. */ +function applySurfaceEvent( + state: SurfaceFoldState, + event: SessionEvent, + expectedSeq: number, +): SurfaceFoldReplacement | undefined { + const plan = planSurfaceEvent(state, event, expectedSeq) + if (plan?.kind === 'append') { + const tail = state.nodes.at(-1) + const node: SurfaceNode = { seq: plan.seq, prev: tail?.seq ?? null, next: null } + if (tail) tail.next = plan.seq + state.nodes.push(node) + state.nodeBySeq.set(plan.seq, node) + } else if (plan?.kind === 'replace') { + replaceSurface(state, plan) + } + if (plan?.kind !== 'replace') return + return { + seq: plan.seq, + start: plan.start, + end: plan.end, + shadowedSeqs: plan.shadowedSeqs, + } } /** @@ -167,14 +289,16 @@ function replaceSurface( * models cannot disagree with `deriveMessages()` about replacement ranges. * @param events - session events in contiguous seq order. * @returns the current surface and every positional replacement. - * @throws when a surface-eligible event lacks its mandatory `surfaceOp`, or a - * replacement names nodes that are absent or reversed on the current surface. + * @throws when any event violates the unified surface contract: metadata must + * be well shaped and type-eligible, event seqs must be contiguous, provenance + * must name unique earlier events, and a positional replacement must name and + * cite its complete range. */ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult { const state = createFoldState() const replacements: SurfaceFoldReplacement[] = [] - for (const event of events) { - const replacement = applySurfaceEvent(state, event) + for (const [index, event] of events.entries()) { + const replacement = applySurfaceEvent(state, event, index) if (replacement !== undefined) replacements.push(replacement) } return { @@ -184,11 +308,10 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult } /** - * Maintains a cached linked list of surface nodes, rebuilt lazily from - * `surfaceOp` markers in the event log. Because the log is append-only, it - * processes only the delta since the last rebuild — new events are folded - * into the existing surface in O(new events) rather than rescanning the - * whole log. + * Maintains a cached linked list of surface nodes and validates each candidate + * before it enters the event log. Because the log is append-only, it processes + * only committed deltas and plans the candidate without mutation rather than + * rescanning the whole log. */ export class SurfaceManager { /** Incremental state shared with the complete surface fold. */ @@ -198,6 +321,18 @@ export class SurfaceManager { constructor(private log: readonly SessionEvent[]) {} + /** + * Validate one candidate as the next log event without applying it. The + * committed log is folded first, then the candidate's complete surface and + * provenance transition is planned atomically; a failure leaves the current + * surface unchanged. + * @param event - candidate event that has not entered `log` yet. + */ + validateNext(event: SessionEvent): void { + if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() + planSurfaceEvent(this._state, event, this.log.length) + } + /** * The surface's rewrite generation, bumped by every folded `replace` op. * A replace is the ONE operation that rewrites the @@ -227,8 +362,8 @@ export class SurfaceManager { // Index is bounded by i < this.log.length — never undefined. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const event = this.log[i]! - applySurfaceEvent(this._state, event) + applySurfaceEvent(this._state, event, i) + this._lastProcessedSeq = i } - this._lastProcessedSeq = this.log.length - 1 } } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index f4f42062fd..1466211b97 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..a598e1f2bd 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' } } }) @@ -295,35 +317,52 @@ describe('Session', () => { type: 'user/message', seq: 0, time: 1, + data: { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } }, + surfaceOp: 'append', + }, { + type: 'user/message', + seq: 1, + time: 2, data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, surfaceOp, + sourceEventSeqs: [0], }] as unknown as SessionEvent[] const session = new Session(SessionId('seed-unstable-metadata'), seed) - const event = session.events[0]! + const event = session.events[1]! if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message') expect(reads).toBe(1) expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) }) - it('adds seed context when surface validation throws a non-Error value', () => { + it.each([ + ['an Error', new Error('validator failed'), 'validator failed'], + ['a non-Error value', 'validator failed', 'invalid surface metadata'], + ] as const)('adds seed context when surface validation throws %s', (_name, failure, expected) => { const originalHasOwn = Object.hasOwn const hasOwn = vi.spyOn(Object, 'hasOwn').mockImplementation((object: object, property: PropertyKey): boolean => { - if ((object as Record)['op'] === 'replace') throw 'validator failed' + if ((object as Record)['op'] === 'replace') throw failure return originalHasOwn(object, property) }) const seed = [{ type: 'user/message', seq: 0, time: 1, + data: { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } }, + surfaceOp: 'append', + }, { + type: 'user/message', + seq: 1, + time: 2, data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, surfaceOp: { op: 'replace', start: 0, end: 0 }, + sourceEventSeqs: [0], }] as unknown as SessionEvent[] try { expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed)) - .toThrow('invalid seed event at index 0: invalid surface metadata') + .toThrow(`invalid seed event at index 1: ${expected}`) } finally { hasOwn.mockRestore() } @@ -409,6 +448,11 @@ describe('Session', () => { it('reads a nested append-metadata getter once and stores its first JSON value', () => { const session = new Session(SessionId('append-unstable-metadata')) + const source = session.append( + 'user/message', + { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) let reads = 0 const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', { enumerable: true, @@ -421,12 +465,12 @@ describe('Session', () => { const event = session.append( 'user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, - { surfaceOp } as never, + { surfaceOp, sourceEventSeqs: [0] } as never, ) expect(reads).toBe(1) expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) - expect(session.events).toEqual([event]) + expect(session.events).toEqual([source, event]) }) it('rejects invalid plain surface metadata shapes at append', () => { @@ -462,7 +506,7 @@ describe('Session', () => { 'turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, { surfaceOp: 'append' }, - )).toThrow(/not surface-eligible and cannot carry surface metadata/) + )).toThrow(/not surface-eligible and cannot carry surfaceOp/) expect(() => new Session(SessionId('non-surface-metadata-seed'), [{ type: 'turn/start', seq: 0, diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index eb15cf3317..1615e12f4b 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' -import { Session, SessionId, foldSurface, isSurfaceEligibleType, isSurfaceEvent } from '@deepseek-ai/dsh-session' +import { + Session, + SessionId, + foldSurface, + isSurfaceEligibleType, + isSurfaceEvent, +} from '@deepseek-ai/dsh-session' import { CallId } from '@deepseek-ai/dsh-llm' /** Build a minimal session with turn boundaries and a single user message. */ @@ -13,6 +19,64 @@ function surfaceSession(): Session { return s } +function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent { + return { + type: 'user/message', + seq, + time: seq, + data: { content: [], source: { kind: 'user' } }, + surfaceOp: 'append', + ...sourceEventSeqs === undefined ? {} : { sourceEventSeqs }, + } as unknown as SessionEvent +} + +describe('foldSurface provenance', () => { + it('accepts absent or valid provenance and complete replacement coverage', () => { + const events = [ + provenanceEvent(0, undefined), + provenanceEvent(1, undefined), + { + ...provenanceEvent(2, [0, 1]), + surfaceOp: { op: 'replace', start: 0, end: 1 }, + }, + ] as SessionEvent[] + expect(() => foldSurface(events)).not.toThrow() + }) + + it('rejects provenance on a non-surface event', () => { + const event = { + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + sourceEventSeqs: [0], + } as unknown as SessionEvent + expect(() => foldSurface([event])).toThrow(/cannot carry sourceEventSeqs/) + }) + + it.each([ + ['a non-array', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: 'invalid' }], /must be an array/], + ['an empty array', [provenanceEvent(0, [])], /must not be empty/], + ['duplicates', [provenanceEvent(0, undefined), provenanceEvent(1, [0, 0])], /must not contain duplicates/], + ['a sparse array', [provenanceEvent(0, Array(1))], /densely contain/], + ['a non-number', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: ['0'] }], /non-negative safe integers/], + ['a fractional number', [provenanceEvent(0, [0.5])], /non-negative safe integers/], + ['a negative number', [provenanceEvent(0, [-1])], /non-negative safe integers/], + ['a self reference', [provenanceEvent(0, [0])], /must reference earlier events/], + ['a non-contiguous event seq', [provenanceEvent(0, undefined), provenanceEvent(2, [1])], /seq 2 is not contiguous; expected 1/], + ['incomplete replacement coverage', [ + provenanceEvent(0, undefined), + provenanceEvent(1, undefined), + { ...provenanceEvent(2, [0]), surfaceOp: { op: 'replace', start: 0, end: 1 } }, + ], /missing 1/], + ] as const)( + 'rejects %s', + (_name, events, expected) => { + expect(() => foldSurface(events as unknown as SessionEvent[])).toThrow(expected) + }, + ) +}) + describe('SurfaceManager', () => { it('shares exact nodes and nested replacement ranges with foldSurface', () => { const s = new Session(SessionId('shared-fold')) @@ -36,7 +100,7 @@ describe('SurfaceManager', () => { it('does not retain fold-only replacement history in incremental state', () => { const s = new Session(SessionId('incremental-state')) s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 } }) + s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] }) expect(s.surface.nodes).toEqual([{ seq: 1, prev: null, next: null }]) const manager = s.surface as unknown as { _state: object } @@ -47,12 +111,29 @@ describe('SurfaceManager', () => { }) it('foldSurface reports the same invalid replacement failures as the incremental manager', () => { - const s = new Session(SessionId('shared-fold-invalid')) - s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 42, end: 0 }, sourceEventSeqs: [0] }) + const events = [ + provenanceEvent(0, undefined), + { ...provenanceEvent(1, [0]), surfaceOp: { op: 'replace', start: 42, end: 0 } }, + ] as SessionEvent[] - expect(() => foldSurface(s.events)).toThrow(/start seq 42 not found/) - expect(() => s.surface.nodes).toThrow(/start seq 42 not found/) + expect(() => foldSurface(events)).toThrow(/start seq 42 not found/) + expect(() => new Session(SessionId('shared-fold-invalid'), events)) + .toThrow(/start seq 42 not found/) + }) + + it('leaves incremental state unchanged when candidate validation fails', () => { + const s = new Session(SessionId('atomic-validation')) + s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + + expect(() => s.append( + 'assistant/message', + { turn: 1, step: 1, content: [{ type: 'text', text: 'invalid' }] }, + { surfaceOp: { op: 'replace', start: 0, end: 0 } }, + )).toThrow(/missing 0/) + + expect(s.events).toHaveLength(1) + s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(s.surface.nodes.map(node => node.seq)).toEqual([0, 1]) }) it('foldSurface rejects a surface-eligible event without its mandatory marker', () => { @@ -64,7 +145,20 @@ describe('SurfaceManager', () => { } expect(() => foldSurface([malformed])) - .toThrow(/surface event "user\/message" \(seq 0\) carries no surfaceOp marker/) + .toThrow(/surface-eligible and requires a surfaceOp marker/) + }) + + it('foldSurface rejects surfaceOp on a non-surface event', () => { + const malformed = { + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + surfaceOp: 'append', + } as unknown as SessionEvent + + expect(() => foldSurface([malformed])) + .toThrow(/not surface-eligible and cannot carry surfaceOp/) }) it('rebuilds a linked list from surfaceOp: append markers', () => { @@ -161,21 +255,19 @@ describe('SurfaceManager', () => { it('throws when replace start is not found', () => { const s = new Session(SessionId('bad-start')) s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0 - s.append('assistant/message', + expect(() => s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] }, - { surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [5, 0] }, - ) - expect(() => s.surface.nodes).toThrow(/surface replace: start seq 5 not found/) + { surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [0] }, + )).toThrow(/surface replace: start seq 5 not found/) }) it('throws when replace end is not found', () => { const s = new Session(SessionId('bad-end')) s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0 - s.append('assistant/message', + expect(() => s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] }, { surfaceOp: { op: 'replace', start: 0, end: 99 }, sourceEventSeqs: [0] }, - ) - expect(() => s.surface.nodes).toThrow(/surface replace: end seq 99 not found/) + )).toThrow(/surface replace: end seq 99 not found/) }) it('throws when start is after end', () => { @@ -183,22 +275,22 @@ describe('SurfaceManager', () => { s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0 s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1 // start=1, end=0 would be reversed order. - s.append('assistant/message', + expect(() => s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] }, { surfaceOp: { op: 'replace', start: 1, end: 0 }, sourceEventSeqs: [1, 0] }, - ) - expect(() => s.surface.nodes).toThrow(/start seq 1.*after end seq 0/) + )).toThrow(/start seq 1.*after end seq 0/) }) it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => { const s = new Session(SessionId('immutable')) - const sources = [10, 20] + s.append('user/message', { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const sources = [0] s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] }, { surfaceOp: 'append', sourceEventSeqs: sources }) // Mutate caller's array after append. - sources.push(30) + sources.push(1) sources[0] = 99 - const logged = s.events[0]! as SurfaceEvent - expect(logged.sourceEventSeqs).toEqual([10, 20]) + const logged = s.events[1]! as SurfaceEvent + expect(logged.sourceEventSeqs).toEqual([0]) }) it('replace starting at non-head position links to previous node correctly', () => { @@ -280,15 +372,17 @@ describe('deriveMessages with surface', () => { describe('Session.append surface opts', () => { it('records sourceEventSeqs and surfaceOp on the event', () => { const s = new Session(SessionId('opts')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) const event = s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] }, - { surfaceOp: 'append', sourceEventSeqs: [3, 5, 7] }, + { surfaceOp: 'append', sourceEventSeqs: [0, 1] }, ) - expect(event.sourceEventSeqs).toEqual([3, 5, 7]) + expect(event.sourceEventSeqs).toEqual([0, 1]) expect(event.surfaceOp).toBe('append') // The logged event matches the returned event. - expect((s.events[0]! as SurfaceEvent).sourceEventSeqs).toEqual([3, 5, 7]) - expect((s.events[0]! as SurfaceEvent).surfaceOp).toBe('append') + expect((s.events[2]! as SurfaceEvent).sourceEventSeqs).toEqual([0, 1]) + expect((s.events[2]! as SurfaceEvent).surfaceOp).toBe('append') }) it('deriveMessages skips a surface node that derives to null (empty assistant/message)', () => { diff --git a/packages/core/session/tests/tool-pairing.spec.ts b/packages/core/session/tests/tool-pairing.spec.ts index eb7b1a6203..db0f2d26f2 100644 --- a/packages/core/session/tests/tool-pairing.spec.ts +++ b/packages/core/session/tests/tool-pairing.spec.ts @@ -241,10 +241,11 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace // summary user/message — appended now, so it carries a high log seq. const u1 = seqOf(s, 'user/message') const result = s.events.find(e => e.type === 'tool/result')!.seq + const shadowedSeqs = s.surface.nodes.map(node => node.seq) s.append('user/message', { content: [{ type: 'text', text: 'CHECKPOINT' }], source: { kind: 'plugin', plugin: 'compact' }, - }, { surfaceOp: { op: 'replace', start: u1, end: result } }) + }, { surfaceOp: { op: 'replace', start: u1, end: result }, sourceEventSeqs: shadowedSeqs }) // The step's own assistant/message lands AFTER the checkpoint in the log, // still inside the open step. s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'a2' }] }, SURFACE) 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/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 1ba64f6326..3097f1abeb 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { 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..de953ac77e 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 = [ @@ -36,20 +36,37 @@ async function pkgName(absDir: string): Promise { return json.name } +async function installWorkspacePackageCopy(absDir: string, target: string): Promise { + await mkdir(dirname(target), { recursive: true }) + await cp(absDir, target, { + recursive: true, + filter: source => !source.split('/').includes('node_modules'), + }) +} + /** * Build a temporary external consumer with built workspace/vendor links and a mock-backed config. * The optional missing-but-disabled plugin verifies load guards accept intentionally fiber-less * entries rather than treating them as import failures. */ -async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promise { +async function makeConsumer( + welcome: string, + disabledBrokenEntry = false, + extraDshPackages: string[] = [], + extraEntries: string[] = [], +): Promise { const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-')) const nm = join(dir, 'node_modules') - for (const rel of dshPackages) { + for (const rel of [...dshPackages, ...extraDshPackages]) { const abs = join(repoRoot, 'packages', rel) const name = await pkgName(abs) const target = join(nm, name) - await mkdir(dirname(target), { recursive: true }) - await symlink(abs, target) + if (extraDshPackages.includes(rel)) { + await installWorkspacePackageCopy(abs, target) + } else { + await mkdir(dirname(target), { recursive: true }) + await symlink(abs, target) + } } for (const v of vendorPackages) { const abs = join(repoRoot, 'vendor', v) @@ -75,7 +92,9 @@ async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promi ' config:', ' model: mock-echo', ' persona: \'demo\'', + ' workspaceContext: false', ` welcome: '${welcome}'`, + ...extraEntries, ...disabledBrokenEntry ? ['- id: off', ' name: \'./src/does-not-exist.ts\'', ' disabled: true'] : [], @@ -147,6 +166,27 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-demo BUILT bin (node lib/bin.j expect(code).toBe(0) }, 30_000) + it('boots when optional spill plugins are loaded from a built consumer install', async () => { + consumer = await makeConsumer( + 'SPILL-OK ready.', + false, + ['spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention'], + [ + '- id: spill-local', + ' name: \'@deepseek-ai/dsh-spill-local\'', + '- id: spill-policy', + ' name: \'@deepseek-ai/dsh-spill-policy\'', + ' config:', + ' maxInlineBytes: 50000', + ], + ) + const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', '') + expect(stderr).not.toContain('failed to load') + expect(stderr).not.toContain('Cannot find package') + expect(stdout).toContain('SPILL-OK ready.') + expect(code).toBe(0) + }, 30_000) + it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => { // boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config // directory cannot break its import; the include plugin's own read must fail loud instead. 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/README.md b/packages/fs/README.md index ec3bb62afb..039cb39ae9 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -1,6 +1,6 @@ # fs/ - filesystem capability family -The filesystem stack: a provider seam (text IO + atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), and the model-facing file tools + executor. All **product** packages. +The filesystem stack: a provider seam (text IO + atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), the model-facing file tools + executor, and the bash-backed discovery tools. All **product** packages. | Package | Role | ctx key | |---|---|---| @@ -8,9 +8,10 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona | `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | | `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) | | `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) | +| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools, backed by fixed ripgrep commands through the bash seam (`ctx.bash`), NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) | -The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. +The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents). ## No timeouts on file IO -`read`/`write`/`edit` take **no** `timeoutMs`, and the provider seam arms no deadline — unlike bash and web, which consume [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md). A local syscall is best-effort-abortable at most: a timeout could not force an in-progress `fsync`/`rename` to stop, so a deadline here would be a knob that cannot deliver on its promise. Adding one would also be an implicit default in the exact place explicit-over-implicit forbids. Both reference agents (Claude Code, Codex) leave file IO untimed for the same reason; cancellation still propagates through the tool-execution signal for best-effort abort at syscall boundaries. +`read`/`write`/`edit` take **no** `timeoutMs`, and the provider seam arms no deadline — unlike bash and web (which consume [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md)) and the bash-backed `glob`/`grep` (whose declared `timeoutMs` is enforced by `@deepseek-ai/dsh-timeout-policy`): those are process-backed, where a deadline can really kill the work. A local syscall is best-effort-abortable at most: a timeout could not force an in-progress `fsync`/`rename` to stop, so a deadline here would be a knob that cannot deliver on its promise. Adding one would also be an implicit default in the exact place explicit-over-implicit forbids. Both reference agents (Claude Code, Codex) leave file IO untimed for the same reason; cancellation still propagates through the tool-execution signal for best-effort abort at syscall boundaries. 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-search/README.md b/packages/fs/tool-fs-search/README.md new file mode 100644 index 0000000000..29afbebfec --- /dev/null +++ b/packages/fs/tool-fs-search/README.md @@ -0,0 +1,90 @@ +# @deepseek-ai/dsh-tool-fs-search + +The **model-facing filesystem discovery tools** — `glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. + +```ts ignore-check +// Default deployment: a bash executor, then the discovery tools. +await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local +await ctx.plugin(ToolFsSearch) // this package — registers glob/grep +// Optional: a spill backend makes capped results fully recoverable. +await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local +``` + +Why bash-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution (local, sandboxed, remote); this package owns schemas, argument validation, shell quoting, parsing, retention, formatted-result spill, and timeout declaration. The tools never call `ctx.bash.start()` and never expose a bash task id — the call returns only after `rg` exits, times out, is aborted, or fails. + +## Deployment requirement: co-located bash + filesystem + +Returned paths are displayed relative to the resolved bash workdir (the calling agent's session cwd when present, else the executor's configured default) and are follow-up-readable with `read` only when the bash workdir and the filesystem root are the same workspace. v1 documents that requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend. + +## Config + +All keys are optional; the defaults are the shipped search caps. + +| Key | Default | Meaning | +|---|---|---| +| `globMaxResults` | `100` | Max paths one `glob` call retains inline (matches Claude Code's `GlobTool` limit); later paths go to the formatted spill artifact. | +| `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. | +| `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. | +| `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. | +| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the bash backend's own timeout stays a second safety cap. | + +## Tools + +| Tool | Arguments | Behavior | +|---|---|---| +| `glob` | `pattern`, `path?` | `rg --files --glob --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one path per line, modification-time ordered. | +| `grep` | `pattern`, `path?`, `include?` | Line-oriented `rg --json` parse (no colon-splitting ambiguity). `pattern` is a ripgrep regex; `path` is an optional **file or directory** target; `include` is ONE positive glob filter — a comma-separated list or a negated (`!…`) value is rejected up front (brace alternation like `*.{ts,tsx}` is fine). Returns matches grouped by file as `Line N: `. | + +Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results follows the returned spill locator's retrieval hint. + +## Two budgets, two artifacts + +Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. The model-facing recovery artifact is different: when a search yields more logical results than the inline cap, the tool saves the COMPLETE formatted result through `ctx.spillStore.saveText()` (suggested names `glob-results.txt` / `grep-results.txt`, owner = the calling session, source = the tool execution identity) and appends a footer naming the returned locator and retrieval hint. This is the first tool-owned spill call in the codebase — deliberate, because retention here is item-level: the generic `@deepseek-ai/dsh-spill-policy` only sees the final text on `tools/post-execute`, by which point a capped search has already omitted later paths/matches. A missing spill backend, a call with no session owner, or a `saveText()` failure keeps the inline page and reports that the complete result could not be saved — never an `isError`. + +## Errors + +Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (missing `rg`, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors. + +## Model Experience + +### System prompt + +**What the model sees**: Every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section. + +**Token effect**: Fixed guidance cost per request while the plugin is active. + +#### Glob guidance + +```markdown +Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files. +``` + +#### Grep guidance + +```markdown +Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. +``` + +### Tool schemas + +**What the model sees**: The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) while this surface is visible. + +**Token effect**: Fixed schema cost on every request where the tools are visible. + +### Results and spill notices + +**What the model sees**: `glob` returns one path per line; `grep` groups `Line : ` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved. + +**Token effect**: Inline paths and matches are bounded by `globMaxResults`, `grepMaxMatches`, and `grepMaxLineBytes`; the call and retained result remain in history until compaction. + +### Tool errors + +**What the model sees**: Failures are normalized as `Error: ` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata for callers. + +**Token effect**: Only a failing call adds these retained tokens. + +## Known Limitations and Deferred Work + +- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation. +- **Ripgrep is a deployment dependency** — a missing or incompatible `rg` executable fails calls with `SEARCH_FAILED`; remote or virtual filesystems need a co-located executor or another search consumer. +- **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend. diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json new file mode 100644 index 0000000000..002d54569a --- /dev/null +++ b/packages/fs/tool-fs-search/package.json @@ -0,0 +1,49 @@ +{ + "name": "@deepseek-ai/dsh-tool-fs-search", + "description": "Model-facing filesystem discovery tools (glob, grep) backed by the DeepSeek Harness bash seam (ctx.bash)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-retention": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-spill": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-retention": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-spill": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts new file mode 100644 index 0000000000..a3e803fb50 --- /dev/null +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -0,0 +1,179 @@ +/** + * The model-facing `glob` tool: discover files whose paths match a glob + * pattern, sorted by modification time. Execution goes through the bash seam + * (`ctx.bash`) with a fixed `rg --files` command — this module owns the + * model-facing schema, argument validation, shell-safe command construction, + * result parsing, retention, and formatting; process concerns (defaulting, + * scrubbing, kill, backend substitution) stay behind `ctx.bash`. + * + * @module @deepseek-ai/dsh-tool-fs-search/glob + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { ItemRetainer } from '@deepseek-ai/dsh-retention' +import type { RetainedItems } from '@deepseek-ai/dsh-retention' +import type { SpillRef } from '@deepseek-ai/dsh-spill' +import type {} from '@deepseek-ai/dsh-bash' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' +import { singleQuote } from './shell-quote.ts' + +/** + * Default cap on paths retained inline by one `glob` call (the `globMaxResults` + * config), matching Claude Code's default `GlobTool` result limit. + */ +export const GLOB_MAX_RESULTS = 100 + +/** + * Directory names ripgrep must never descend into for a discovery listing: VCS + * metadata stores. `--no-ignore --hidden` would otherwise surface them in every + * broad search. Each name is excluded with TWO negated `--glob`s (see + * {@link buildGlobCommand}): an any-depth directory glob that matches — and + * prunes — the directory during traversal, and a contents glob that still + * excludes the internals when the search root itself is at or inside the + * directory (an explicit `path` of `.git` or `sub/.git`), where the prune glob + * alone never matches. + */ +export const GLOB_VCS_EXCLUDES: readonly string[] = ['.git', '.svn', '.hg', '.bzr', '.jj', '.sl'] + +/** Resolved glob-tool caps — plugin config after defaulting (see `Config` in index.ts). */ +export interface GlobToolCaps { + /** Max paths retained inline; later paths go to the formatted spill file. */ + maxResults: number + /** Cap on the complete raw `rg` stdout the tool will parse. */ + rawOutputMaxBytes: number + /** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */ + timeoutMs: number +} + +/** Validated `glob` arguments. */ +export interface GlobInput { + pattern: string + path?: string +} + +/** + * Validate value constraints the schema DSL can't express: a non-blank + * `pattern`, and a non-blank `path` when given. Throws a plain `Error` (an + * ordinary tool argument error) otherwise. + * + * @param args - the schema-validated `glob` arguments. + * @returns the accepted input, unchanged. + */ +export function parseGlobArgs(args: { pattern: string; path?: string }): GlobInput { + if (args.pattern.trim().length === 0) throw new Error('pattern must be a non-empty string') + if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given') + return { pattern: args.pattern, ...args.path !== undefined ? { path: args.path } : {} } +} + +/** + * Build the fixed `rg --files` command for one `glob` call. Every + * model-controlled value ({@link GlobInput.pattern}, {@link GlobInput.path}) + * passes through {@link singleQuote}; the search root rides behind `--` so a + * leading-dash path can never be parsed as a flag. `--sort=modified` orders by + * modification time, `--no-ignore --hidden` searches ignored and hidden files, + * and {@link GLOB_VCS_EXCLUDES} keeps VCS metadata out. + * + * @param input - the validated arguments. + * @returns the complete, shell-safe command string. + */ +export function buildGlobCommand(input: GlobInput): string { + const parts = [ + 'rg --files', + `--glob=${singleQuote(input.pattern)}`, + '--sort=modified --no-ignore --hidden', + // Two negated globs per VCS name: the bare form prunes the directory + // during traversal; the /** form still excludes the contents when the + // search root is AT or INSIDE the directory (where the bare form, + // matched against root-prefixed paths, never fires). + ...GLOB_VCS_EXCLUDES.flatMap(name => [ + `--glob=${singleQuote(`!**/${name}`)}`, + `--glob=${singleQuote(`!**/${name}/**`)}`, + ]), + ] + if (input.path !== undefined) parts.push('--', singleQuote(input.path)) + return parts.join(' ') +} + +/** + * Format the model-facing `glob` result: the retained paths, then — when the + * result was capped — a footer carrying either the formatted-spill recovery + * locator or the could-not-save explanation. The omitted count is a budget fact: + * the search itself completed. + * + * @param retained - the retention outcome over every discovered path. + * @param spillRef - the saved complete-result reference, or `undefined` when unsaved. + * @returns the model-facing text. + */ +export function formatGlobOutput(retained: RetainedItems, spillRef: SpillRef | undefined): string { + const body = retained.items.join('\n') + if (!retained.truncated) return body + const recovery = spillRef !== undefined + ? `Full sorted result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}` + : 'The complete result could not be saved; narrow pattern or path to see more.' + return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})` +} + +/** + * Pending-call presentation: a search card titled by the pattern (and root). + * + * @param args - the raw tool arguments; `pattern` and `path` feed the title. + * @returns the generic card view (`kind: 'search'`) shown while the call runs. + */ +export function presentGlobCall(args: { pattern: string; path?: string }): GenericCallView { + const where = args.path !== undefined ? ` in ${args.path}` : '' + return { card: 'generic', title: `Glob ${args.pattern}${where}`, kind: 'search', rawInput: args.pattern } +} + +/** + * Register the `glob` tool and its system-prompt guidance. + * + * @param ctx - the plugin context; registrations are effects scoped to it, and + * execution uses its `bash` service. + * @param caps - the deployment's resolved glob caps (plugin config after defaulting). + */ +export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { + ctx.systemPrompt.section({ + name: 'tool:glob', + order: 103, + text: 'Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.', + }) + + ctx.tools.register(defineTool({ + name: 'glob', + description: 'Find files whose paths match a glob pattern. Returns matching paths sorted by modification time, ' + + 'including hidden and ignored files (VCS metadata directories are excluded). ' + + `Returns the first ${caps.maxResults} paths inline; a capped result reports where the complete list was saved.`, + parameters: { + pattern: { type: 'string', required: true, description: 'Glob pattern to match file paths against (e.g. "**/*.ts", "src/**/*.test.js").' }, + path: { type: 'string', description: 'Directory to search in. Defaults to the session workspace; a relative path resolves against it.' }, + }, + timeoutMs: caps.timeoutMs, + async execute(args, exec): Promise { + const input = parseGlobArgs(args) + const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes) + if (run.noMatches) return [{ type: 'text', text: 'No files found' }] + + const retainer = new ItemRetainer({ kind: 'head', maxItems: caps.maxResults }) + const all: string[] = [] + for (const line of run.stdout.split('\n')) { + if (line.length === 0) continue + const displayPath = toWorkdirRelative(line, run.workdir) + all.push(displayPath) + retainer.push(displayPath) + } + const retained = retainer.finish() + + // The complete sorted list is the recovery artifact; save it only when + // the inline page omitted paths (an uncapped result needs no spill file). + const spillRef = retained.truncated + ? await trySaveFormattedResult(ctx, exec, 'glob-results.txt', all.join('\n')) + : undefined + return [{ type: 'text', text: formatGlobOutput(retained, spillRef) }] + }, + presentCall: presentGlobCall, + })) +} diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts new file mode 100644 index 0000000000..3935513b73 --- /dev/null +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -0,0 +1,315 @@ +/** + * The model-facing `grep` tool: search file contents with a ripgrep regular + * expression. Execution goes through the bash seam (`ctx.bash`) with a fixed + * line-oriented `rg --json` command so file path, line number, and line text + * parse without colon-splitting ambiguity — this module owns the model-facing + * schema, argument validation, shell-safe command construction, `--json` + * record parsing, per-line preview retention, match retention, grouping, and + * formatting; process concerns stay behind `ctx.bash`. + * + * @module @deepseek-ai/dsh-tool-fs-search/grep + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention' +import type { RetainedItems } from '@deepseek-ai/dsh-retention' +import type { SpillRef } from '@deepseek-ai/dsh-spill' +import type {} from '@deepseek-ai/dsh-bash' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' +import { singleQuote } from './shell-quote.ts' + +/** + * Default cap on flat matches retained inline by one `grep` call (the + * `grepMaxMatches` config), matching Claude Code's default `GrepTool` + * `head_limit`. + */ +export const GREP_MAX_MATCHES = 250 + +/** + * Default cap in bytes on one matched-line preview (the `grepMaxLineBytes` + * config); the cut preserves UTF-8 boundaries. + */ +export const GREP_MAX_LINE_BYTES = 2000 + +/** Resolved grep-tool caps — plugin config after defaulting (see `Config` in index.ts). */ +export interface GrepToolCaps { + /** Max flat matches retained inline; later matches go to the formatted spill file. */ + maxMatches: number + /** Max bytes retained per matched-line preview. */ + maxLineBytes: number + /** Cap on the complete raw `rg` stdout the tool will parse. */ + rawOutputMaxBytes: number + /** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */ + timeoutMs: number +} + +/** Validated `grep` arguments. */ +export interface GrepInput { + pattern: string + path?: string + include?: string +} + +/** One parsed match: the file, the 1-based line number, and the (possibly previewed) line text. */ +export interface GrepMatch { + path: string + lineNumber: number + line: string +} + +/** + * Reject an `include` that is not ONE positive glob filter: blank strings, + * negated patterns (`!…`), and comma-separated lists. A comma inside a brace + * group is fine — `*.{ts,tsx}` is one glob with alternation, not a list. + */ +function validateInclude(include: string): void { + if (include.trim().length === 0) throw new Error('include must be a non-empty glob when given') + if (include.startsWith('!')) throw new Error('include must be a positive glob filter; negated patterns ("!…") are not supported') + let braceDepth = 0 + for (const char of include) { + if (char === '{') braceDepth++ + else if (char === '}') braceDepth = Math.max(0, braceDepth - 1) + else if (char === ',' && braceDepth === 0) { + throw new Error('include must be one glob, not a comma-separated list (use {a,b} alternation instead)') + } + } +} + +/** + * Validate value constraints the schema DSL can't express: a non-EMPTY + * `pattern` (whitespace is a legitimate regex), a non-blank `path` when given, + * and a single positive `include` glob ({@link GrepInput}). Throws a plain + * `Error` (an ordinary tool argument error) otherwise. + * + * @param args - the schema-validated `grep` arguments. + * @returns the accepted input, unchanged. + */ +export function parseGrepArgs(args: { pattern: string; path?: string; include?: string }): GrepInput { + if (args.pattern.length === 0) throw new Error('pattern must be a non-empty string') + if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given') + if (args.include !== undefined) validateInclude(args.include) + return { + pattern: args.pattern, + ...args.path !== undefined ? { path: args.path } : {}, + ...args.include !== undefined ? { include: args.include } : {}, + } +} + +/** + * Build the fixed line-oriented `rg --json` command for one `grep` call. Every + * model-controlled value ({@link GrepInput.pattern}, {@link GrepInput.path}, + * {@link GrepInput.include}) passes through {@link singleQuote}; the pattern + * and include ride in `--flag=value` form and the target behind `--`, so a + * leading-dash value can never be parsed as a flag. + * + * @param input - the validated arguments. + * @returns the complete, shell-safe command string. + */ +export function buildGrepCommand(input: GrepInput): string { + const parts = ['rg --json', `--regexp=${singleQuote(input.pattern)}`] + if (input.include !== undefined) parts.push(`--glob=${singleQuote(input.include)}`) + if (input.path !== undefined) parts.push('--', singleQuote(input.path)) + return parts.join(' ') +} + +/** + * The uniform malformed-output failure: raw `rg --json` is an internal + * transport, so a shape surprise is a search failure, not a partial result. + */ +function malformedRecord(detail: string, cause?: unknown): SearchError { + return new SearchError(`grep received malformed ripgrep --json output (${detail})`, 'SEARCH_FAILED', cause !== undefined ? { cause } : undefined) +} + +/** + * Parse one `rg --json` NDJSON line into a match, `undefined` for the + * non-match record types (`begin`/`end`/`context`/`summary`). A line that is + * not JSON, or a `match` record missing its path / line number / line content, + * throws {@link SearchError} `SEARCH_FAILED`. A match whose line is not valid + * UTF-8 (ripgrep sends base64 `bytes` instead of `text`) yields a placeholder + * preview rather than failing the whole search. + */ +function parseRecord(line: string): GrepMatch | undefined { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch (error: unknown) { + throw malformedRecord('a line is not JSON', error) + } + if (typeof parsed !== 'object' || parsed === null) throw malformedRecord('a record is not an object') + const record = parsed as { type?: unknown; data?: unknown } + // Non-match record types (begin/end/context/summary — and any future type) + // are transport framing, not results: skipped, not malformed. + if (record.type !== 'match') return undefined + if (typeof record.data !== 'object' || record.data === null) throw malformedRecord('a match record has no data') + const data = record.data as { path?: unknown; line_number?: unknown; lines?: unknown } + const pathText = typeof data.path === 'object' && data.path !== null ? (data.path as { text?: unknown }).text : undefined + if (typeof pathText !== 'string') throw malformedRecord('a match record has no path text') + if (typeof data.line_number !== 'number') throw malformedRecord('a match record has no line number') + if (typeof data.lines !== 'object' || data.lines === null) throw malformedRecord('a match record has no line content') + const lines = data.lines as { text?: unknown; bytes?: unknown } + if (typeof lines.text === 'string') { + return { path: pathText, lineNumber: data.line_number, line: lines.text.replace(/\r?\n$/, '') } + } + if (typeof lines.bytes === 'string') { + return { path: pathText, lineNumber: data.line_number, line: '(line is not valid UTF-8)' } + } + throw malformedRecord('a match record has neither line text nor bytes') +} + +/** + * Parse complete `rg --json` stdout into flat matches, in output order (ripgrep + * emits one file's matches contiguously). Only `match` records are consumed. + * + * @param stdout - the complete raw `rg --json` stdout. + * @returns the flat matches; empty for output with no match records. + */ +export function parseGrepMatches(stdout: string): GrepMatch[] { + const matches: GrepMatch[] = [] + for (const line of stdout.split('\n')) { + if (line.length === 0) continue + const match = parseRecord(line) + if (match !== undefined) matches.push(match) + } + return matches +} + +/** + * Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and + * mark the cut. The cap is a per-line budget fact; the complete line stays in + * the searched file for `read`. + * + * @param line - the matched line text (trailing newline already stripped). + * @param maxBytes - the preview budget in bytes. + * @returns the preview, suffixed with ` (line truncated)` when bytes were cut. + */ +export function previewLine(line: string, maxBytes: number): string { + const retainer = new TextRetainer({ kind: 'head', maxBytes }) + retainer.push(line) + const kept = retainer.finish() + return kept.truncated ? `${kept.text} (line truncated)` : kept.text +} + +/** `match` / `matches` for a count. */ +function matchNoun(count: number): string { + return count === 1 ? 'match' : 'matches' +} + +/** + * Group flat matches by file (first-seen order) into the model-facing body: + * each file's display path, then one `Line N: ` row per match. + * + * @param matches - the flat matches to render. + * @returns the grouped body text. + */ +export function formatGrepMatches(matches: GrepMatch[]): string { + const byFile = new Map() + for (const match of matches) { + const group = byFile.get(match.path) + if (group !== undefined) group.push(match) + else byFile.set(match.path, [match]) + } + const sections: string[] = [] + for (const [path, group] of byFile) { + sections.push(`${path}\n${group.map(m => `Line ${m.lineNumber}: ${m.line}`).join('\n')}`) + } + return sections.join('\n\n') +} + +/** + * Format the model-facing `grep` result: a found-count header, the retained + * matches grouped by file, then — when the result was capped — a footer + * carrying either the formatted-spill recovery locator or the could-not-save + * explanation. The omitted count is a budget fact: the search itself completed. + * + * @param retained - the retention outcome over every parsed match. + * @param spillRef - the saved complete-result reference, or `undefined` when unsaved. + * @returns the model-facing text. + */ +export function formatGrepOutput(retained: RetainedItems, spillRef: SpillRef | undefined): string { + const header = retained.truncated + ? `Found ${retained.kept} of ${retained.seen} matches` + : `Found ${retained.seen} ${matchNoun(retained.seen)}` + const body = formatGrepMatches(retained.items) + if (!retained.truncated) return `${header}\n\n${body}` + const recovery = spillRef !== undefined + ? `Full grep result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}` + : 'The complete result could not be saved; narrow pattern, path, or include to see more.' + return `${header}\n\n${body}\n\n(${recovery})` +} + +/** + * Pending-call presentation: a search card titled by the pattern (and target / + * include filter). + * + * @param args - the raw tool arguments; `pattern`, `path`, and `include` feed the title. + * @returns the generic card view (`kind: 'search'`) shown while the call runs. + */ +export function presentGrepCall(args: { pattern: string; path?: string; include?: string }): GenericCallView { + const where = args.path !== undefined ? ` in ${args.path}` : '' + const filter = args.include !== undefined ? ` (${args.include})` : '' + return { card: 'generic', title: `Grep ${args.pattern}${where}${filter}`, kind: 'search', rawInput: args.pattern } +} + +/** + * Register the `grep` tool and its system-prompt guidance. + * + * @param ctx - the plugin context; registrations are effects scoped to it, and + * execution uses its `bash` service. + * @param caps - the deployment's resolved grep caps (plugin config after defaulting). + */ +export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { + ctx.systemPrompt.section({ + name: 'tool:grep', + order: 104, + text: 'Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.', + }) + + ctx.tools.register(defineTool({ + name: 'grep', + description: 'Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. ' + + `Returns the first ${caps.maxMatches} matches inline; a capped result reports where the complete match list was saved. ` + + 'Use read on a matched file for surrounding context.', + parameters: { + pattern: { type: 'string', required: true, description: 'Regular expression to search for (ripgrep syntax).' }, + path: { type: 'string', description: 'File or directory to search. Defaults to the session workspace; a relative path resolves against it.' }, + include: { type: 'string', description: 'One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported.' }, + }, + timeoutMs: caps.timeoutMs, + async execute(args, exec): Promise { + const input = parseGrepArgs(args) + const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes) + if (run.noMatches) return [{ type: 'text', text: 'No matches found' }] + + const retainer = new ItemRetainer({ kind: 'head', maxItems: caps.maxMatches }) + const all: GrepMatch[] = [] + for (const raw of parseGrepMatches(run.stdout)) { + const match: GrepMatch = { + path: toWorkdirRelative(raw.path, run.workdir), + lineNumber: raw.lineNumber, + line: previewLine(raw.line, caps.maxLineBytes), + } + all.push(match) + retainer.push(match) + } + const retained = retainer.finish() + + // The spill file stores the FULL formatted match list (same grouped, + // per-line-previewed shape the model saw), so read offset/limit pages the + // same logical result; save only when the inline page omitted matches. + const spillRef = retained.truncated + ? await trySaveFormattedResult( + ctx, + exec, + 'grep-results.txt', + `Found ${all.length} ${matchNoun(all.length)}\n\n${formatGrepMatches(all)}`, + ) + : undefined + return [{ type: 'text', text: formatGrepOutput(retained, spillRef) }] + }, + presentCall: presentGrepCall, + })) +} diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts new file mode 100644 index 0000000000..8c33d5770a --- /dev/null +++ b/packages/fs/tool-fs-search/src/index.ts @@ -0,0 +1,110 @@ +/** + * The model-facing filesystem discovery tool suite (`glob`, `grep`) over the + * bash executor seam (`ctx.bash`). This single plugin registers both tools. + * + * ## Bash-backed, not a `ctx.fs` provider method + * + * Local workspace discovery is a process-backed `rg` workflow, so these tools + * execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` with fixed + * ripgrep command templates — never `ctx.bash.start()`, never a model-visible + * background task. The tool layer owns schemas, argument validation, shell + * quoting ({@link module:@deepseek-ai/dsh-tool-fs-search/shell-quote}), result + * parsing, retention, formatted-result spill, and timeout declaration; the + * bash executor owns request defaulting/capping, subprocess execution, + * process-group termination, environment scrubbing, raw output capture, and + * backend substitution. The package injects `tools`, `systemPrompt`, and + * `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read opportunistically + * with `ctx.get()` because formatted-result spill is optional. + * + * Returned paths are displayed relative to the resolved bash workdir and are + * follow-up-readable only in co-located deployments where the bash workdir and + * the filesystem `read` root are the same workspace — a documented v1 + * deployment requirement, not runtime-validated. + * + * @module @deepseek-ai/dsh-tool-fs-search + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts' +import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts' +import { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts' + +export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall } from './glob.ts' +export type { GlobInput, GlobToolCaps } from './glob.ts' +export { + GREP_MAX_LINE_BYTES, + GREP_MAX_MATCHES, + applyGrepTool, + buildGrepCommand, + formatGrepMatches, + formatGrepOutput, + parseGrepArgs, + parseGrepMatches, + presentGrepCall, + previewLine, +} from './grep.ts' +export type { GrepInput, GrepMatch, GrepToolCaps } from './grep.ts' +export { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS, SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' +export type { RipgrepRun, SearchErrorCode } from './search-core.ts' +export { singleQuote } from './shell-quote.ts' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'tool-fs-search' + +/** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */ +export const inject = ['tools', 'systemPrompt', 'bash'] + +/** Plugin config (all optional — `Config` supplies the defaults). */ +export interface Config { + /** Max paths one `glob` call retains inline; later paths go to the formatted spill file. */ + globMaxResults?: number + /** Max flat matches one `grep` call retains inline; later matches go to the formatted spill file. */ + grepMaxMatches?: number + /** Max bytes retained for one matched-line preview (the cut preserves UTF-8 boundaries). */ + grepMaxLineBytes?: number + /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ + rawOutputMaxBytes?: number + /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */ + timeoutMs?: number +} + +export const Config: z = z.object({ + globMaxResults: z.number().default(GLOB_MAX_RESULTS), + grepMaxMatches: z.number().default(GREP_MAX_MATCHES), + grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES), + rawOutputMaxBytes: z.number().default(RAW_OUTPUT_MAX_BYTES), + timeoutMs: z.number().default(SEARCH_TIMEOUT_MS), +}) + +/** The shape after schemastery applied the defaults. */ +type ResolvedConfig = Required + +/** Every search cap counts items/bytes/milliseconds — a positive integer, or retention and timeout arithmetic misbehaves silently. */ +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`tool-fs-search: ${name} must be a positive integer`) + } +} + +/** Register the `glob`/`grep` filesystem discovery tool suite. */ +export function apply(ctx: Context, config: Config): void { + // schemastery (Config) has already filled every defaulted field. + const resolved = config as ResolvedConfig + assertPositiveInteger('globMaxResults', resolved.globMaxResults) + assertPositiveInteger('grepMaxMatches', resolved.grepMaxMatches) + assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes) + assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes) + assertPositiveInteger('timeoutMs', resolved.timeoutMs) + applyGlobTool(ctx, { + maxResults: resolved.globMaxResults, + rawOutputMaxBytes: resolved.rawOutputMaxBytes, + timeoutMs: resolved.timeoutMs, + }) + applyGrepTool(ctx, { + maxMatches: resolved.grepMaxMatches, + maxLineBytes: resolved.grepMaxLineBytes, + rawOutputMaxBytes: resolved.rawOutputMaxBytes, + timeoutMs: resolved.timeoutMs, + }) +} diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts new file mode 100644 index 0000000000..0682c86e35 --- /dev/null +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -0,0 +1,262 @@ +/** + * Shared execution plumbing for the `glob` / `grep` search tools: the + * package-owned `SEARCH_*` error vocabulary, one bash-seam run helper that + * turns a fixed `rg` command into complete raw stdout, the best-effort + * formatted-result spill handoff, and workdir-relative path display. + * + * Both tools execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` + * as ordinary foreground tool calls — never `ctx.bash.start()`, never a + * model-visible background task. Raw `rg` stdout is an internal transport + * detail: the tools request a per-run stdout capture budget from the bash seam, + * parse only complete in-memory stdout within `rawOutputMaxBytes`, and never + * read executor spill files. The model-facing recovery artifact is the + * formatted result saved through `ctx.spillStore.saveText()` + * ({@link trySaveFormattedResult}). + * + * @module @deepseek-ai/dsh-tool-fs-search/search-core + */ + +import { isAbsolute, relative, sep } from 'node:path' +import type { Context } from 'cordis' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' + +/** + * Default cap on the complete raw `rg` stdout the tools will parse (the + * `rawOutputMaxBytes` config), matching Claude Code's ripgrep raw buffer. + */ +export const RAW_OUTPUT_MAX_BYTES = 20_000_000 + +/** + * Default cooperative tool-call timeout budget in milliseconds (the `timeoutMs` + * config), attached to both tool definitions for + * `@deepseek-ai/dsh-timeout-policy` to enforce through `exec.signal`. + */ +export const SEARCH_TIMEOUT_MS = 30_000 + +/** + * Stable, machine-routable codes for search failures. Package-owned (not + * `FsErrorCode`) because these tools are bash-backed discovery, not `ctx.fs` + * provider operations: `SEARCH_INVALID_PATTERN` — ripgrep rejected the regex or + * glob; `SEARCH_FAILED` — the search could not run or its output could not be + * parsed (missing `rg`, inaccessible target, signal kill, malformed `--json`); + * `SEARCH_RAW_OUTPUT_OVERFLOW` — raw `rg` output exceeded `rawOutputMaxBytes` + * or stayed truncated after that requested stdout budget; `SEARCH_ABORTED` — the tool + * timeout, caller cancellation, or the bash executor's own timeout cut the + * search short. + */ +export type SearchErrorCode = + | 'SEARCH_INVALID_PATTERN' + | 'SEARCH_FAILED' + | 'SEARCH_RAW_OUTPUT_OVERFLOW' + | 'SEARCH_ABORTED' + +/** + * Typed search failure. Extends {@link HarnessError} so it carries a stable + * {@link SearchErrorCode} and chains `cause`; the tool registry surfaces + * `{ name, code }` on `isError` results so retry/permission/UI layers can + * branch without parsing messages. + */ +export class SearchError extends HarnessError { + override readonly code: SearchErrorCode + + constructor(message: string, code: SearchErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.code = code + } +} + +/** The completed acquisition of one `rg` run: complete stdout plus the resolved workdir. */ +export interface RipgrepRun { + /** Complete raw stdout retained by the bash executor within the requested cap. */ + stdout: string + /** True when ripgrep exited 1: a successful search with zero results. */ + noMatches: boolean + /** The resolved working directory the command ran in (the display-relativization base). */ + workdir: string +} + +/** + * The retained stderr tail as a diagnostic excerpt, with a truncation note when + * the executor dropped bytes (the tool never reads `stderr.spillPath`). + */ +function stderrExcerpt(stderr: CollectedOutput): string { + const text = stderr.text.trim() + if (text.length === 0) return '' + return stderr.truncated ? `${text} [stderr truncated]` : text +} + +/** Classify a nonzero-exit `rg` run into the search error vocabulary (invalid pattern vs missing `rg` vs everything else). */ +function classifyRunFailure(toolName: string, result: BashRunResult): SearchError { + const stderr = stderrExcerpt(result.stderr) + if (/regex parse error|error parsing glob/i.test(stderr)) { + return new SearchError(`${toolName} pattern rejected by ripgrep: ${stderr}`, 'SEARCH_INVALID_PATTERN') + } + if (result.exitCode === 127 || /command not found/i.test(stderr)) { + return new SearchError(`${toolName} requires ripgrep (rg) on the bash executor's PATH${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED') + } + return new SearchError(`${toolName} search failed (exit ${result.exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED') +} + +/** + * Acquire the COMPLETE raw stdout of a finished run, enforcing + * `rawOutputMaxBytes` on the in-memory transport. A truncated result means the + * bash backend could not retain complete stdout within the requested budget, so + * the tool fails clearly instead of parsing a silently-partial stream. + */ +function completeStdout(toolName: string, result: BashRunResult, rawOutputMaxBytes: number): string { + const narrow = 'narrow pattern, path, or include and retry' + if (!result.stdout.truncated) { + const inlineBytes = Buffer.byteLength(result.stdout.text, 'utf8') + if (inlineBytes > rawOutputMaxBytes) { + throw new SearchError( + `${toolName} produced ${inlineBytes} bytes of raw output, over the ${rawOutputMaxBytes}-byte cap; ${narrow}`, + 'SEARCH_RAW_OUTPUT_OVERFLOW', + ) + } + return result.stdout.text + } + throw new SearchError( + `${toolName} produced more raw output than the bash executor retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`, + 'SEARCH_RAW_OUTPUT_OVERFLOW', + ) +} + +/** + * Run one fixed `rg` command through the bash seam and return its complete raw + * stdout. The bash request workdir is the calling agent's session cwd + * (`exec.agent.session.header.cwd`) when available — mirroring `dsh-tool-bash` / + * `dsh-tool-fs` — else omitted so the implementation's `resolve()` applies its + * configured default. `exec.signal` is forwarded so the cooperative tool + * timeout (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation kill the + * command; the bash backend's own timeout stays a second safety cap. + * + * Exit semantics are tool-owned: exit 0 is success with results, exit 1 is + * success with zero results (`noMatches`), anything else throws a + * {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern → + * `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` / + * `SEARCH_RAW_OUTPUT_OVERFLOW`). A `run()` REJECTION — the seam's + * infrastructure failures (pre-aborted signal, unusable workdir, missing + * shell) — is translated into the same taxonomy: a pre-aborted signal becomes + * `SEARCH_ABORTED`, everything else `SEARCH_FAILED`, with the original as + * `cause`. + * + * @param ctx - the plugin context; execution uses its `bash` service. + * @param exec - the tool-execution context; supplies the session cwd and the abort signal. + * @param toolName - `glob` or `grep`, used in error messages. + * @param command - the fully-quoted `rg` command string (every model value already through `singleQuote`). + * @param rawOutputMaxBytes - cap on the complete raw stdout the tool will parse. + * @returns the complete stdout, the zero-result flag, and the resolved workdir. + */ +export async function runRipgrep( + ctx: Context, + exec: ToolExecution, + toolName: string, + command: string, + rawOutputMaxBytes: number, +): Promise { + const cwd = exec.agent?.session.header.cwd + const spec = ctx.bash.resolve({ + command, + stdoutMaxBytes: rawOutputMaxBytes, + ...cwd !== undefined ? { workdir: cwd } : {}, + ...exec.signal ? { signal: exec.signal } : {}, + }) + let result: BashRunResult + try { + result = await ctx.bash.run(spec) + } catch (error: unknown) { + // The seam contract: run() REJECTS only for infrastructure failures — a + // pre-aborted signal, an unusable workdir, a missing shell. Translate them + // so these failures stay machine-routable under the SEARCH_* taxonomy. + if (spec.signal?.aborted === true) { + throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED', { cause: error }) + } + throw new SearchError(`${toolName} could not start its search command (unusable working directory or missing shell)`, 'SEARCH_FAILED', { cause: error }) + } + if (result.aborted) { + throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED') + } + if (result.timedOut) { + throw new SearchError(`${toolName} timed out after ${result.timeoutMs}ms in the bash executor; narrow pattern, path, or include and retry`, 'SEARCH_ABORTED') + } + if (result.signal !== null || result.exitCode === null) { + throw new SearchError(`${toolName} search command was killed by signal ${result.signal ?? '(unknown)'}`, 'SEARCH_FAILED') + } + if (result.exitCode !== 0 && result.exitCode !== 1) { + throw classifyRunFailure(toolName, result) + } + const stdout = completeStdout(toolName, result, rawOutputMaxBytes) + return { stdout, noMatches: result.exitCode === 1, workdir: spec.workdir } +} + +/** + * Map an `rg` output path to its display form: absolute paths inside the + * resolved bash workdir become workdir-relative; everything else (relative + * output, paths outside the workdir) passes through unchanged. Display-only — + * returned paths are follow-up-readable in co-located bash/filesystem + * deployments where both resolve the same workspace (the documented v1 + * deployment requirement). + * + * @param path - one path as ripgrep printed it. + * @param workdir - the resolved bash workdir the command ran in. + * @returns the workdir-relative display path when possible, else `path` unchanged. + */ +export function toWorkdirRelative(path: string, workdir: string): string { + if (!isAbsolute(path)) return path + const rel = relative(workdir, path) + if (rel.length === 0) return '.' + if (rel === '..' || rel.startsWith(`..${sep}`)) return path + return rel +} + +/** + * Best-effort save of one COMPLETE formatted search result through + * `ctx.spillStore.saveText()` — the model-facing recovery path for a capped + * result. `spillStore` is read with `ctx.get()` (not static inject) because + * formatted-result spill is optional; the spill owner is the calling agent's + * session header id and the source is the tool execution identity. A missing + * backend, a call with no session owner, or a `saveText()` rejection logs a + * warning and returns `undefined` — the caller keeps the inline result and + * reports that the complete result could not be saved; search success never + * turns into `isError` because spill storage is unavailable. + * + * @param ctx - the plugin context; `spillStore` is looked up opportunistically. + * @param exec - the tool-execution context; supplies the owning session, tool name, and call id. + * @param suggestedName - the backend-sanitized filename hint (e.g. `grep-results.txt`). + * @param content - the complete formatted result to persist. + * @returns the saved spill reference, or `undefined` when the result could not be saved. + */ +export async function trySaveFormattedResult( + ctx: Context, + exec: ToolExecution, + suggestedName: string, + content: string, +): Promise { + const sessionId = exec.agent?.session.header.id + if (sessionId === undefined) { + ctx.logger.warn(`tool-fs-search: no session owner for ${exec.name} result; complete result not saved`) + return undefined + } + const spillStore = ctx.get('spillStore') + if (!spillStore) { + ctx.logger.warn(`tool-fs-search: no ctx.spillStore backend loaded; complete ${exec.name} result not saved`) + return undefined + } + const save: SaveTextSpill = { + owner: { sessionId }, + source: { toolName: exec.name, callId: exec.callId, label: 'result' }, + suggestedName, + content, + } + try { + return await spillStore.saveText(save) + } catch (error: unknown) { + // Best-effort: a storage failure must never fail the search or hide the + // inline result — the footer reports the unsaved remainder instead. + ctx.logger.warn(`tool-fs-search: saveText failed for ${exec.name}: ${String(error)}; complete result not saved`) + return undefined + } +} diff --git a/packages/fs/tool-fs-search/src/shell-quote.ts b/packages/fs/tool-fs-search/src/shell-quote.ts new file mode 100644 index 0000000000..9453b8e255 --- /dev/null +++ b/packages/fs/tool-fs-search/src/shell-quote.ts @@ -0,0 +1,27 @@ +/** + * The one shell-quoting helper both search tools MUST route every + * model-controlled value through before it enters an `rg` command string. The + * bash seam (`ctx.bash`) accepts a command STRING, not an argv vector, so this + * is the safety boundary that stops a `pattern`, `path`, or `include` from + * breaking out of its argument and injecting shell syntax. + * + * Command builders in `glob.ts` / `grep.ts` must never hand-roll quoting or + * concatenate an unquoted model value — they call {@link singleQuote}. + * + * @module @deepseek-ai/dsh-tool-fs-search/shell-quote + */ + +/** + * POSIX single-quote a string for safe use as ONE shell word. Wraps the value + * in single quotes and rewrites every embedded single quote as `'\''` (close + * quote, an escaped literal quote, reopen quote). Inside single quotes the shell + * treats every other byte literally — spaces, newlines, `$`, backticks, `;`, + * `|`, `&`, glob metacharacters, and a leading `-` are all inert — so the result + * is a single, injection-safe argument regardless of the input. + * + * @param value - the raw, possibly model-controlled string to quote. + * @returns the value wrapped as one safe single-quoted shell word. + */ +export function singleQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'` +} diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts new file mode 100644 index 0000000000..36fb2c28e6 --- /dev/null +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -0,0 +1,190 @@ +/** + * Integration tests: the REAL local bash executor (`dsh-bash-local`) plus a + * REAL ripgrep binary, exercised through `ctx.tools.execute()`. These verify + * the WORLD — actual files on disk are discovered and grepped, hostile + * patterns stay inert in a real shell, and real `rg` stderr classifies into + * the `SEARCH_*` vocabulary. The whole suite self-skips when `rg` is not on + * PATH (a CI accommodation mirroring the keyless e2e skip); the fake-executor + * suite (tools.spec.ts) carries the coverage gate. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { spawnSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' + +const hasRg = spawnSync('rg', ['--version'], { encoding: 'utf8' }).status === 0 + +let dir: string +let ctx: Context + +let callCounter = 0 +function call(name: string, args: unknown, agentObj?: object) { + return ctx.tools.execute({ + callId: CallId(`it-${++callCounter}`), + name, + arguments: args, + ...agentObj ? { agent: agentObj as never } : {}, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () => { + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-search-int-')) + await mkdir(join(dir, 'src'), { recursive: true }) + await mkdir(join(dir, '.git'), { recursive: true }) + await mkdir(join(dir, 'spaced dir'), { recursive: true }) + await writeFile(join(dir, 'src', 'alpha.ts'), 'export const alpha = 1\n// TODO: refit alpha\n') + await writeFile(join(dir, 'src', 'beta.ts'), 'export const beta = 2\n') + await writeFile(join(dir, 'notes.md'), 'alpha appears here too\n') + await writeFile(join(dir, '.hidden.ts'), 'export const hidden = 3\n') + await writeFile(join(dir, '.git', 'config.ts'), 'never listed\n') + await writeFile(join(dir, 'spaced dir', "wei'rd \"name\".ts"), 'const inside = true\n') + // Deterministic --sort=modified order: alpha oldest, beta newest. + await utimes(join(dir, 'src', 'alpha.ts'), new Date(2000, 0, 1), new Date(2000, 0, 1)) + await utimes(join(dir, 'src', 'beta.ts'), new Date(2020, 0, 1), new Date(2020, 0, 1)) + + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 }) + await ctx.plugin(ToolFsSearch) + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + describe('glob', () => { + it('discovers files by pattern, sorted by modification time, hidden included, .git excluded', async () => { + const result = await call('glob', { pattern: '**/*.ts' }) + expect(result.isError).toBe(false) + const paths = text(result).split('\n') + expect(paths.indexOf('src/alpha.ts')).toBeLessThan(paths.indexOf('src/beta.ts')) + expect(paths).toContain('.hidden.ts') + expect(paths).toContain("spaced dir/wei'rd \"name\".ts") + expect(paths).not.toContain('.git/config.ts') + expect(paths).not.toContain('notes.md') + }) + + it('scopes to a directory search root (path arg)', async () => { + const result = await call('glob', { pattern: '*.ts', path: 'src' }) + expect(text(result).split('\n').sort()).toEqual(['src/alpha.ts', 'src/beta.ts']) + }) + + it('reports zero discoveries as No files found', async () => { + expect(text(await call('glob', { pattern: '*.nomatch' }))).toBe('No files found') + }) + + it('excludes VCS internals even when the search root IS the VCS directory', async () => { + // The prune glob alone never matches root-prefixed paths when rg is + // rooted at .git; the paired contents glob keeps the exclusion airtight. + expect(text(await call('glob', { pattern: '*', path: '.git' }))).toBe('No files found') + }) + + it('classifies an invalid glob as SEARCH_INVALID_PATTERN', async () => { + const result = await call('glob', { pattern: '[' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' }) + }) + }) + + describe('grep', () => { + it('greps a directory tree with grouped, line-numbered output', async () => { + const result = await call('grep', { pattern: 'alpha' }) + expect(result.isError).toBe(false) + const output = text(result) + expect(output).toContain('Found 3 matches') + expect(output).toContain('src/alpha.ts\nLine 1: export const alpha = 1\nLine 2: // TODO: refit alpha') + expect(output).toContain('notes.md\nLine 1: alpha appears here too') + }) + + it('greps a single FILE target', async () => { + const result = await call('grep', { pattern: 'alpha', path: 'notes.md' }) + expect(text(result)).toBe('Found 1 match\n\nnotes.md\nLine 1: alpha appears here too') + }) + + it('greps a directory target with an include filter', async () => { + const result = await call('grep', { pattern: 'alpha', path: '.', include: '*.ts' }) + const output = text(result) + expect(output).toContain('alpha.ts') + expect(output).not.toContain('notes.md') + }) + + it('a hostile pattern stays inert (no command substitution, the world untouched)', async () => { + const canary = join(dir, 'pwned') + const result = await call('grep', { pattern: `$(touch ${canary})` }) + expect(result.isError).toBe(false) // exit 1: found nothing, executed nothing + expect(text(result)).toBe('No matches found') + expect(spawnSync('test', ['-e', canary]).status).not.toBe(0) + }) + + it('a leading-dash pattern is a pattern, not a flag', async () => { + await writeFile(join(dir, 'dashes.txt'), 'value --flag value\n') + const result = await call('grep', { pattern: '--flag', path: 'dashes.txt' }) + expect(text(result)).toBe('Found 1 match\n\ndashes.txt\nLine 1: value --flag value') + }) + + it('classifies a real rg regex error as SEARCH_INVALID_PATTERN', async () => { + const result = await call('grep', { pattern: '(unclosed' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' }) + }) + + it('classifies a missing target as SEARCH_FAILED', async () => { + const result = await call('grep', { pattern: 'x', path: 'no-such-dir' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + }) + }) + + describe('per-session cwd', () => { + it('resolves the search in the SESSION workspace, not the executor config cwd', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-search-session-')) + try { + await writeFile(join(sessionDir, 'only-here.ts'), 'const sessionFile = true\n') + const agentObj = { session: { header: { id: 'session-int', cwd: sessionDir } } } + const globbed = await call('glob', { pattern: '*.ts' }, agentObj) + expect(text(globbed)).toBe('only-here.ts') + const grepped = await call('grep', { pattern: 'sessionFile' }, agentObj) + expect(text(grepped)).toContain('only-here.ts\nLine 1: const sessionFile = true') + } finally { + await rm(sessionDir, { recursive: true, force: true }) + } + }) + }) + + describe('bash-start infrastructure failures stay in the SEARCH_* taxonomy', () => { + it('a pre-aborted exec.signal (real executor rejects before spawn) is SEARCH_ABORTED', async () => { + const controller = new AbortController() + controller.abort() + const result = await ctx.tools.execute({ + callId: CallId(`it-${++callCounter}`), + name: 'grep', + arguments: { pattern: 'x' }, + signal: controller.signal, + }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + }) + + it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => { + const gone = join(dir, 'deleted-session-dir') + const result = await call('glob', { pattern: '*' }, { session: { header: { id: 'session-int', cwd: gone } } }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' }) + expect(text(result)).toContain('could not start') + }) + }) +}) diff --git a/packages/fs/tool-fs-search/tests/load-path.spec.ts b/packages/fs/tool-fs-search/tests/load-path.spec.ts new file mode 100644 index 0000000000..d3c28619a3 --- /dev/null +++ b/packages/fs/tool-fs-search/tests/load-path.spec.ts @@ -0,0 +1,50 @@ +/** + * Real-load-path guard for @deepseek-ai/dsh-tool-fs-search. `tool-fs-search` is + * a NAMESPACE plugin with `inject` — so a stray `export default apply` would + * make the cordis Loader's `unwrapExports` (`exports.default ?? exports`) + * collapse the module to the bare `apply` function, DROPPING `inject`. The + * plugin would then read `ctx.bash` without having injected it and throw + * `cannot get property … without inject` the moment it loads (postmortem 0001). + * + * A hand-built `ctx.plugin({ apply, inject })` mount CANNOT catch that — it + * bypasses `unwrapExports`. So this test unwraps the module through the REAL + * `Loader.prototype.unwrapExports` and mounts the result over a bash executor, + * exercising the exact path the Loader uses. Prove the guard bites: add + * `export default apply` to `src/index.ts`, watch this go red, revert. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search' + +describe('dsh-tool-fs-search real-load-path guard', () => { + it('has no default export and keeps name/inject/Config through unwrapExports', () => { + expect('default' in toolFsSearch).toBe(false) + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(toolFsSearch) as Record + expect(unwrapped).toBe(toolFsSearch) + expect(unwrapped.name).toBe('tool-fs-search') + expect(unwrapped.inject).toEqual(['tools', 'systemPrompt', 'bash']) + expect(typeof unwrapped.Config).toBe('function') + expect(typeof unwrapped.apply).toBe('function') + }) + + it('boots over ctx.bash through the unwrapped module without an inject error', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalBashExecutor, {}) + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters[0] + // A collapsed export shape (dropped inject) would throw "without inject" here. + const fiber = await ctx.plugin(unwrapped) + expect(ctx.tools.schemas().map(s => s.name)).toEqual(expect.arrayContaining(['glob', 'grep'])) + await fiber.dispose() + }) +}) diff --git a/packages/fs/tool-fs-search/tests/shell-quote.spec.ts b/packages/fs/tool-fs-search/tests/shell-quote.spec.ts new file mode 100644 index 0000000000..84c8506be1 --- /dev/null +++ b/packages/fs/tool-fs-search/tests/shell-quote.spec.ts @@ -0,0 +1,59 @@ +/** + * Unit tests for the shell-quoting safety boundary, plus a REAL round-trip: + * every adversarial value, quoted, must survive `bash -c "printf '%s' "` + * byte-for-byte — proving the quoting is inert in an actual shell, not just + * against a mental model of one. + */ + +import { describe, expect, it } from 'vitest' +import { spawnSync } from 'node:child_process' +import { singleQuote } from '@deepseek-ai/dsh-tool-fs-search' + +/** Adversarial values a model could pass as pattern / path / include. */ +const HOSTILE: readonly string[] = [ + 'plain', + 'with spaces', + "it's got 'quotes'", + '"double quoted"', + '$(rm -rf /tmp/nope)', + '`touch /tmp/nope`', + '$HOME and ${PATH}', + 'semi;colon && chain || pipe | bg &', + 'newline\nin the middle', + '-leading-dash', + '--leading-double-dash', + '*?[a-z]{x,y}', + '!bang', + '\\backslash\\', + '~tilde', + '# not a comment', + '>redirect &1', +] + +describe('singleQuote', () => { + it('wraps a plain value in single quotes', () => { + expect(singleQuote('abc')).toBe("'abc'") + }) + + it("rewrites embedded single quotes as '\\''", () => { + expect(singleQuote("a'b")).toBe("'a'\\''b'") + expect(singleQuote("''")).toBe("''\\'''\\'''") + }) + + it.each(HOSTILE.map(value => [JSON.stringify(value), value] as const))( + 'round-trips %s through a real bash -c unchanged', + (_label, value) => { + const result = spawnSync('bash', ['-c', `printf '%s' ${singleQuote(value)}`], { encoding: 'utf8' }) + expect(result.status).toBe(0) + expect(result.stdout).toBe(value) + }, + ) + + it('a quoted command substitution does not execute (the world stays untouched)', () => { + const canary = `/tmp/dsh-quote-canary-${process.pid}` + const result = spawnSync('bash', ['-c', `printf '%s' ${singleQuote(`$(touch ${canary})`)}`], { encoding: 'utf8' }) + expect(result.stdout).toBe(`$(touch ${canary})`) + // The canary file must NOT exist — the substitution stayed literal. + expect(spawnSync('test', ['-e', canary]).status).not.toBe(0) + }) +}) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts new file mode 100644 index 0000000000..5363344f07 --- /dev/null +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -0,0 +1,632 @@ +/** + * Consumer-surface tests for the search tools over a FAKE bash executor and a + * FAKE spill backend, exercised through `ctx.tools.execute()` so nothing + * bypasses the tool registry. The fake executor makes every seam outcome + * scriptable — truncated stdout with/without a raw spill path, abort/timeout, + * signal kills, ripgrep exit codes — so these tests verify schemas, argument + * validation, shell-safe command construction, workdir derivation, signal + * forwarding, `SEARCH_*` error classification, retention, formatted-result + * spill handoff, and the no-background-task invariant. Real-`rg` behavior is + * pinned separately in integration.spec.ts. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' +import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' +import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' +import { + buildGlobCommand, + buildGrepCommand, + formatGrepMatches, + parseGrepMatches, + presentGlobCall, + presentGrepCall, + previewLine, + toWorkdirRelative, +} from '@deepseek-ai/dsh-tool-fs-search' + +/** A successful run result over the given stdout; overrides script the failure shapes. */ +function runResult(stdout: string, overrides?: Partial): BashRunResult { + return { + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 60_000, + stdout: { text: stdout, truncated: false }, + stderr: { text: '', truncated: false }, + ...overrides, + } +} + +/** + * A scriptable fake executor: `resolve()` mirrors the real request→spec + * defaulting (workdir falls back to `/work`), `run()` returns whatever the + * test armed via `handler`, and `start()` throws — the search tools must NEVER + * create a background task. + */ +class FakeBash extends BashExecutor { + requests: BashExecRequest[] = [] + specs: BashExecSpec[] = [] + startCalls = 0 + handler: (spec: BashExecSpec) => BashRunResult = () => runResult('') + + override resolve(request: BashExecRequest): BashExecSpec { + this.requests.push(request) + return { + command: request.command, + workdir: request.workdir ?? '/work', + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + signal: request.signal, + sandboxMode: request.sandboxMode, + } + } + override run(spec: BashExecSpec): Promise { + this.specs.push(spec) + return Promise.resolve(this.handler(spec)) + } + override start(): BashProcess { + this.startCalls++ + throw new Error('search tools must never start a background task') + } +} + +/** A recording spill backend; arm `failWith` to script a storage failure. */ +class FakeSpill extends SpillStore { + saves: SaveTextSpill[] = [] + failWith?: Error + + override saveText(input: SaveTextSpill): Promise { + if (this.failWith) return Promise.reject(this.failWith) + this.saves.push(input) + return Promise.resolve({ + locator: SpillLocator(`/spill/${input.suggestedName}`), + bytes: Buffer.byteLength(input.content, 'utf8'), + retrievalHint: 'Use the fake retrieval hint.', + }) + } +} + +interface SetupOptions { + config?: ToolFsSearch.Config + spill?: boolean +} + +async function setup(options: SetupOptions = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeBash) + if (options.spill === true) await ctx.plugin(FakeSpill) + const fiber = await ctx.plugin(ToolFsSearch, options.config) + const bash = ctx.bash as FakeBash + const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined + return { ctx, bash, spill, fiber } +} + +/** A stand-in agent whose session header carries the given cwd (and a stable id). */ +const agent = (cwd?: string) => ({ session: { header: { id: 'session-1', ...cwd !== undefined ? { cwd } : {} } } }) + +let callCounter = 0 +function call(ctx: Context, name: string, args: unknown, options: { agent?: object; signal?: AbortSignal } = {}) { + return ctx.tools.execute({ + callId: CallId(`call-${++callCounter}`), + name, + arguments: args, + ...options.agent ? { agent: options.agent as never } : {}, + ...options.signal ? { signal: options.signal } : {}, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +/** One rg --json match record line. */ +function matchLine(path: string, lineNumber: number, lineText: string): string { + return JSON.stringify({ type: 'match', data: { path: { text: path }, lines: { text: lineText }, line_number: lineNumber, absolute_offset: 0, submatches: [] } }) +} + +describe('registration', () => { + it('registers glob and grep with their prompt sections', async () => { + const { ctx } = await setup() + expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['glob', 'grep']) + const prompt = renderPrompt(await ctx.systemPrompt.assemble()) + expect(prompt).toContain('Use the glob tool') + expect(prompt).toContain('Use the grep tool') + }) + + it('stays pending until ctx.bash exists (inject)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(ToolFsSearch) // no bash executor + expect(ctx.tools.schemas()).toHaveLength(0) + }) + + it('unregisters everything on fiber disposal (HMR safety)', async () => { + const { ctx, fiber } = await setup() + expect(ctx.tools.schemas()).toHaveLength(2) + await fiber.dispose() + expect(ctx.tools.schemas()).toHaveLength(0) + const sections = (await ctx.systemPrompt.assemble()).sections.map(s => s.name) + expect(sections).not.toContain('tool:glob') + expect(sections).not.toContain('tool:grep') + }) + + it('attaches the configured timeoutMs to both tool definitions', async () => { + const { ctx } = await setup({ config: { timeoutMs: 5000 } }) + expect(ctx.tools.get('glob')?.timeoutMs).toBe(5000) + expect(ctx.tools.get('grep')?.timeoutMs).toBe(5000) + }) + + it('defaults the timeout budget to 30 seconds', async () => { + const { ctx } = await setup() + expect(ctx.tools.get('glob')?.timeoutMs).toBe(30_000) + expect(ctx.tools.get('grep')?.timeoutMs).toBe(30_000) + }) +}) + +describe('config validation', () => { + it.each([ + ['globMaxResults', { globMaxResults: 0 }], + ['grepMaxMatches', { grepMaxMatches: -1 }], + ['grepMaxLineBytes', { grepMaxLineBytes: 1.5 }], + ['rawOutputMaxBytes', { rawOutputMaxBytes: 0 }], + ['timeoutMs', { timeoutMs: -100 }], + ] as const)('rejects a non-positive or fractional %s at load', async (name, config) => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeBash) + await expect(ctx.plugin(ToolFsSearch, config)).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`)) + }) +}) + +describe('command construction (shell-safe)', () => { + it('glob: fixed rg --files template with quoted pattern and paired VCS excludes', () => { + const command = buildGlobCommand({ pattern: '**/*.ts' }) + expect(command).toBe( + "rg --files --glob='**/*.ts' --sort=modified --no-ignore --hidden " + + "--glob='!**/.git' --glob='!**/.git/**' --glob='!**/.svn' --glob='!**/.svn/**' " + + "--glob='!**/.hg' --glob='!**/.hg/**' --glob='!**/.bzr' --glob='!**/.bzr/**' " + + "--glob='!**/.jj' --glob='!**/.jj/**' --glob='!**/.sl' --glob='!**/.sl/**'", + ) + }) + + it('glob: the search root rides behind -- and is quoted', () => { + const command = buildGlobCommand({ pattern: '*.md', path: 'docs dir' }) + expect(command).toContain("-- 'docs dir'") + }) + + it('grep: fixed rg --json template with the pattern in --regexp= form', () => { + expect(buildGrepCommand({ pattern: 'foo.*bar' })).toBe("rg --json --regexp='foo.*bar'") + }) + + it('grep: include and path are quoted, include in --glob= form, path behind --', () => { + const command = buildGrepCommand({ pattern: 'x', path: '-leading-dash', include: '*.{ts,tsx}' }) + expect(command).toBe("rg --json --regexp='x' --glob='*.{ts,tsx}' -- '-leading-dash'") + }) + + it.each([ + ['a command-substitution pattern', '$(rm -rf /)', "'$(rm -rf /)'"], + ['a backtick pattern', '`touch pwned`', "'`touch pwned`'"], + ['a pattern with double quotes and spaces', 'say "hi there"', '\'say "hi there"\''], + ['a pattern with single quotes', "it's", '\'it\'\\\'\'s\''], + ['a pattern with newlines', 'a\nb', "'a\nb'"], + ['a leading-dash pattern', '--flag', "'--flag'"], + ['glob metacharacters', '*?[a-z]{x,y}', "'*?[a-z]{x,y}'"], + ])('quotes %s into one inert shell word', (_label, raw, quoted) => { + expect(buildGrepCommand({ pattern: raw })).toBe(`rg --json --regexp=${quoted}`) + }) +}) + +describe('workdir derivation and signal forwarding', () => { + it('forwards the session cwd as the request workdir', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('a.ts\n') + await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') }) + expect(bash.requests[0]?.workdir).toBe('/sessions/s1') + expect(bash.specs[0]?.workdir).toBe('/sessions/s1') + }) + + it('omits the request workdir without a session cwd so resolve() defaults apply', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('a.ts\n') + await call(ctx, 'glob', { pattern: '*' }, { agent: agent() }) + expect(bash.requests[0]).not.toHaveProperty('workdir') + expect(bash.specs[0]?.workdir).toBe('/work') + // A non-agent caller takes the same default path. + await call(ctx, 'grep', { pattern: 'x' }) + expect(bash.requests[1]).not.toHaveProperty('workdir') + }) + + it('forwards exec.signal into the bash spec (the abort reaches the backend)', async () => { + const { ctx, bash } = await setup() + const controller = new AbortController() + controller.abort() + bash.handler = spec => runResult('', { aborted: spec.signal?.aborted === true }) + const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) + expect(bash.specs[0]?.signal).toBe(controller.signal) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + expect(text(result)).toContain('aborted') + }) + + it('reports the bash executor timeout as SEARCH_ABORTED with the budget', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { timedOut: true, timeoutMs: 1234, exitCode: null, signal: 'SIGTERM' }) + const result = await call(ctx, 'glob', { pattern: '*' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'SEARCH_ABORTED' }) + expect(text(result)).toContain('timed out after 1234ms') + }) + + it('translates a run() rejection under a pre-aborted signal into SEARCH_ABORTED', async () => { + // The seam contract: run() REJECTS for a pre-aborted signal (it never + // spawns). The plain rejection must not escape the SEARCH_* taxonomy. + const { ctx, bash } = await setup() + const controller = new AbortController() + controller.abort() + bash.handler = () => { throw new Error('aborted before spawn') } + const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + }) + + it('translates a run() rejection without an abort (unusable workdir) into SEARCH_FAILED', async () => { + const { ctx, bash } = await setup() + bash.handler = () => { throw new Error('spawn bash ENOENT') } + const result = await call(ctx, 'glob', { pattern: '*' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' }) + expect(text(result)).toContain('could not start') + }) +}) + +describe('exit semantics and failure classification', () => { + it('exit 1 is a successful empty search', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { exitCode: 1 }) + const glob = await call(ctx, 'glob', { pattern: '*.nope' }) + expect(glob.isError).toBe(false) + expect(text(glob)).toBe('No files found') + const grep = await call(ctx, 'grep', { pattern: 'nope' }) + expect(grep.isError).toBe(false) + expect(text(grep)).toBe('No matches found') + }) + + it('a regex parse error classifies as SEARCH_INVALID_PATTERN', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: regex parse error:\n (\nerror: unclosed group', truncated: false } }) + const result = await call(ctx, 'grep', { pattern: '(' }) + expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' }) + expect(text(result)).toContain('regex parse error') + }) + + it('a glob parse error classifies as SEARCH_INVALID_PATTERN', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: error parsing glob \'[\': unclosed character class', truncated: false } }) + const result = await call(ctx, 'glob', { pattern: '[' }) + expect(result.error).toMatchObject({ code: 'SEARCH_INVALID_PATTERN' }) + }) + + it('a missing rg binary classifies as SEARCH_FAILED naming ripgrep', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { exitCode: 127, stderr: { text: 'bash: line 1: rg: command not found', truncated: false } }) + const result = await call(ctx, 'glob', { pattern: '*' }) + expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(text(result)).toContain('requires ripgrep (rg)') + // The same classification holds from either evidence alone: the 127 exit + // with silent stderr, or a shell's command-not-found text on another exit. + bash.handler = () => runResult('', { exitCode: 127 }) + expect(text(await call(ctx, 'glob', { pattern: '*' }))).toContain('requires ripgrep (rg)') + bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'sh: rg: command not found', truncated: false } }) + expect(text(await call(ctx, 'grep', { pattern: 'x' }))).toContain('requires ripgrep (rg)') + }) + + it('other nonzero exits are SEARCH_FAILED carrying the stderr excerpt', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'rg: missing.dir: IO error: no such file or directory', truncated: false } }) + const result = await call(ctx, 'grep', { pattern: 'x', path: 'missing.dir' }) + expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(text(result)).toContain('IO error') + }) + + it('a nonzero exit with EMPTY stderr still reports the exit code', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { exitCode: 3 }) + const result = await call(ctx, 'glob', { pattern: '*' }) + expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(text(result)).toContain('exit 3') + }) + + it('truncated stderr gains a truncation note and stderr.spillPath is never read', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { + exitCode: 2, + stderr: { text: 'tail of diagnostics', truncated: true, spillPath: '/does/not/exist-and-never-read' }, + }) + const result = await call(ctx, 'grep', { pattern: 'x' }) + expect(text(result)).toContain('tail of diagnostics [stderr truncated]') + }) + + it('a signal kill (not timeout, not abort) is SEARCH_FAILED', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { exitCode: null, signal: 'SIGKILL' }) + const result = await call(ctx, 'grep', { pattern: 'x' }) + expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + expect(text(result)).toContain('SIGKILL') + }) + + it('a null exit with no signal (defensive) is SEARCH_FAILED', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { exitCode: null, signal: null }) + const result = await call(ctx, 'glob', { pattern: '*' }) + expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' }) + }) +}) + +describe('raw output acquisition', () => { + it('passes rawOutputMaxBytes to bash as the stdout capture budget', async () => { + const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 1234 } }) + bash.handler = () => runResult('', { exitCode: 1 }) + await call(ctx, 'glob', { pattern: '*.ts' }) + await call(ctx, 'grep', { pattern: 'needle' }) + expect(bash.requests.map(request => request.stdoutMaxBytes)).toEqual([1234, 1234]) + expect(bash.specs.map(spec => spec.stdoutMaxBytes)).toEqual([1234, 1234]) + }) + + it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when truncated stdout has a raw spill path', async () => { + const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } }) + bash.handler = () => runResult('', { stdout: { text: 'x', truncated: true, spillPath: '/does/not/get-read' } }) + const result = await call(ctx, 'glob', { pattern: '*' }) + expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' }) + expect(text(result)).toContain('narrow pattern, path, or include') + }) + + it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when UNTRUNCATED inline stdout exceeds the cap', async () => { + // An executor retaining more inline than this package's cap (or a + // deployment lowering rawOutputMaxBytes below the bash retention) must not + // smuggle an over-cap parse through the untruncated path. + const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } }) + bash.handler = () => runResult(`${'x'.repeat(64)}\n`) + const result = await call(ctx, 'grep', { pattern: 'x' }) + expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_RAW_OUTPUT_OVERFLOW' }) + expect(text(result)).toContain('narrow pattern, path, or include') + }) + + it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when truncated stdout has no spill path', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { stdout: { text: 'partial', truncated: true } }) + const result = await call(ctx, 'grep', { pattern: 'x' }) + expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' }) + }) +}) + +describe('glob results', () => { + it('lists workdir-relative paths (absolute output under the workdir is relativized)', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n') + const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') }) + expect(text(result)).toBe('src/a.ts\n/elsewhere/b.ts\nrel/c.ts') + }) + + it('validates arguments (blank pattern, blank path)', async () => { + const { ctx } = await setup() + expect(text(await call(ctx, 'glob', { pattern: ' ' }))).toContain('pattern must be a non-empty string') + expect(text(await call(ctx, 'glob', { pattern: '*', path: ' ' }))).toContain('path must be a non-empty string') + }) + + it('threads a valid path through to the command as the quoted search root', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('sub/a.ts\n') + const result = await call(ctx, 'glob', { pattern: '*.ts', path: 'sub' }) + expect(result.isError).toBe(false) + expect(bash.specs[0]?.command).toContain("-- 'sub'") + }) + + it('caps at globMaxResults and saves the FULL sorted list through spillStore', async () => { + const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true }) + bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n') + const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') }) + expect(result.isError).toBe(false) + expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result stored at: /spill/glob-results.txt. Use the fake retrieval hint.)') + expect(spill?.saves).toHaveLength(1) + expect(spill?.saves[0]).toMatchObject({ + owner: { sessionId: 'session-1' }, + source: { toolName: 'glob', label: 'result' }, + suggestedName: 'glob-results.txt', + content: 'a.ts\nb.ts\nc.ts\nd.ts', + }) + expect(spill?.saves[0]?.source.callId).toBeDefined() + }) + + it('does not create a spill file when the result fits inline', async () => { + const { ctx, bash, spill } = await setup({ spill: true }) + bash.handler = () => runResult('a.ts\nb.ts\n') + const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') }) + expect(text(result)).toBe('a.ts\nb.ts') + expect(spill?.saves).toHaveLength(0) + }) + + it.each([ + ['no spill backend loaded', { fail: false, spill: false, ownerless: false }], + ['saveText fails', { fail: true, spill: true, ownerless: false }], + ['no session owner', { fail: false, spill: true, ownerless: true }], + ])('keeps the inline page and reports the unsaved remainder when %s', async (_label, mode) => { + const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: mode.spill }) + if (mode.fail && spill) spill.failWith = new Error('disk full') + bash.handler = () => runResult('a.ts\nb.ts\n') + const result = await call(ctx, 'glob', { pattern: '*' }, mode.ownerless ? {} : { agent: agent('/w') }) + expect(result.isError).toBe(false) // spill unavailability never fails the search + expect(text(result)).toBe('a.ts\n\n(Showing 1 of 2 paths. The complete result could not be saved; narrow pattern or path to see more.)') + }) +}) + +describe('grep results', () => { + it('groups matches by file with line numbers', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult([ + JSON.stringify({ type: 'begin', data: { path: { text: 'a.ts' } } }), + matchLine('a.ts', 3, 'const x = 1\n'), + matchLine('a.ts', 9, 'const y = 2\n'), + JSON.stringify({ type: 'end', data: { path: { text: 'a.ts' } } }), + matchLine('b.ts', 1, 'const z = 3'), + JSON.stringify({ type: 'summary', data: {} }), + '', + ].join('\n')) + const result = await call(ctx, 'grep', { pattern: 'const' }) + expect(result.isError).toBe(false) + expect(text(result)).toBe('Found 3 matches\n\na.ts\nLine 3: const x = 1\nLine 9: const y = 2\n\nb.ts\nLine 1: const z = 3') + }) + + it('reports a single match in the singular', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult(`${matchLine('a.ts', 1, 'hit')}\n`) + expect(text(await call(ctx, 'grep', { pattern: 'hit' }))).toBe('Found 1 match\n\na.ts\nLine 1: hit') + }) + + it('relativizes absolute match paths against the resolved workdir', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult(`${matchLine('/sessions/s1/deep/a.ts', 2, 'hit')}\n`) + const result = await call(ctx, 'grep', { pattern: 'hit', path: '/sessions/s1' }, { agent: agent('/sessions/s1') }) + expect(text(result)).toContain('deep/a.ts\nLine 2: hit') + }) + + it('previews a long matched line at grepMaxLineBytes preserving UTF-8', async () => { + const { ctx, bash } = await setup({ config: { grepMaxLineBytes: 7 } }) + // 'héllo wörld' cut at 7 bytes lands mid-'é'? h(1)é(2)l(1)l(1)o(1)=6, space=7 → clean cut at 7. + // Use a multibyte straddle instead: 'aé' repeated — cut at 7 bytes: a(1)é(2)a(1)é(2)=6 +a(1)=7 → next é straddles: trimmed. + bash.handler = () => runResult(`${matchLine('a.txt', 1, 'aéaéaéaé')}\n`) + const result = await call(ctx, 'grep', { pattern: 'a' }) + expect(text(result)).toContain('Line 1: aéaéa (line truncated)') + }) + + it('renders a non-UTF-8 line (rg bytes form) as a placeholder instead of failing', async () => { + const { ctx, bash } = await setup() + const record = JSON.stringify({ type: 'match', data: { path: { text: 'bin.dat' }, lines: { bytes: 'AAECww==' }, line_number: 4 } }) + bash.handler = () => runResult(`${record}\n`) + expect(text(await call(ctx, 'grep', { pattern: 'x' }))).toContain('Line 4: (line is not valid UTF-8)') + }) + + it('strips a CRLF terminator from the matched line text', () => { + const matches = parseGrepMatches(`${matchLine('a.txt', 1, 'windows line\r\n')}\n`) + expect(matches[0]?.line).toBe('windows line') + }) + + it('caps at grepMaxMatches and spills the full formatted match list', async () => { + const { ctx, bash, spill } = await setup({ config: { grepMaxMatches: 2 }, spill: true }) + bash.handler = () => runResult([ + matchLine('a.ts', 1, 'one'), + matchLine('a.ts', 2, 'two'), + matchLine('b.ts', 3, 'three'), + '', + ].join('\n')) + const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') }) + expect(text(result)).toBe('Found 2 of 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\n(Full grep result stored at: /spill/grep-results.txt. Use the fake retrieval hint.)') + expect(spill?.saves[0]).toMatchObject({ + source: { toolName: 'grep', label: 'result' }, + suggestedName: 'grep-results.txt', + content: 'Found 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\nb.ts\nLine 3: three', + }) + }) + + it('reports the unsaved remainder when capped with no spill backend', async () => { + const { ctx, bash } = await setup({ config: { grepMaxMatches: 1 } }) + bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n${matchLine('a.ts', 2, 'two')}\n`) + const result = await call(ctx, 'grep', { pattern: 'o' }, { agent: agent('/w') }) + expect(result.isError).toBe(false) + expect(text(result)).toBe('Found 1 of 2 matches\n\na.ts\nLine 1: one\n\n(The complete result could not be saved; narrow pattern, path, or include to see more.)') + }) + + it('validates arguments (empty pattern, blank path, bad include)', async () => { + const { ctx } = await setup() + expect(text(await call(ctx, 'grep', { pattern: '' }))).toContain('pattern must be a non-empty string') + expect(text(await call(ctx, 'grep', { pattern: 'x', path: ' ' }))).toContain('path must be a non-empty string') + expect(text(await call(ctx, 'grep', { pattern: 'x', include: ' ' }))).toContain('include must be a non-empty glob') + expect(text(await call(ctx, 'grep', { pattern: 'x', include: '!*.ts' }))).toContain('negated patterns') + expect(text(await call(ctx, 'grep', { pattern: 'x', include: '*.ts,*.js' }))).toContain('comma-separated list') + }) + + it('accepts a whitespace-only pattern (a legitimate regex) and brace alternation in include', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { exitCode: 1 }) + const result = await call(ctx, 'grep', { pattern: ' ', include: '*.{ts,tsx}' }) + expect(result.isError).toBe(false) + }) +}) + +describe('rg --json transport failures (SEARCH_FAILED)', () => { + it.each([ + ['a non-JSON line', 'not json at all'], + ['a non-object record', '42'], + ['a match record with no data', JSON.stringify({ type: 'match' })], + ['a match record with no path text', JSON.stringify({ type: 'match', data: { path: {}, lines: { text: 'x' }, line_number: 1 } })], + ['a match record with a non-object path', JSON.stringify({ type: 'match', data: { path: 'a.ts', lines: { text: 'x' }, line_number: 1 } })], + ['a match record with no line number', JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, lines: { text: 'x' } } })], + ['a match record with no line content', JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, line_number: 1 } })], + ['a match record with neither text nor bytes', JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' }, lines: {}, line_number: 1 } })], + ])('%s fails the search', async (_label, line) => { + const { ctx, bash } = await setup() + bash.handler = () => runResult(`${line}\n`) + const result = await call(ctx, 'grep', { pattern: 'x' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' }) + }) +}) + +describe('the no-background-task invariant', () => { + it('never calls ctx.bash.start() across successful and failed searches', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('a.ts\n') + await call(ctx, 'glob', { pattern: '*' }) + bash.handler = () => runResult('', { exitCode: 2, stderr: { text: 'boom', truncated: false } }) + await call(ctx, 'grep', { pattern: 'x' }) + expect(bash.startCalls).toBe(0) + }) +}) + +describe('presentation', () => { + it('glob titles carry the pattern and optional root', () => { + expect(presentGlobCall({ pattern: '**/*.ts' })).toMatchObject({ card: 'generic', title: 'Glob **/*.ts', kind: 'search' }) + expect(presentGlobCall({ pattern: '*.md', path: 'docs' }).title).toBe('Glob *.md in docs') + }) + + it('grep titles carry the pattern, target, and include filter', () => { + expect(presentGrepCall({ pattern: 'todo' })).toMatchObject({ card: 'generic', title: 'Grep todo', kind: 'search' }) + expect(presentGrepCall({ pattern: 'todo', path: 'src', include: '*.ts' }).title).toBe('Grep todo in src (*.ts)') + }) +}) + +describe('helpers', () => { + it('toWorkdirRelative maps inside-workdir absolutes and passes everything else through', () => { + expect(toWorkdirRelative('/w/a/b.ts', '/w')).toBe('a/b.ts') + expect(toWorkdirRelative('/w', '/w')).toBe('.') + expect(toWorkdirRelative('/other/b.ts', '/w')).toBe('/other/b.ts') + expect(toWorkdirRelative('/w-sibling/b.ts', '/w')).toBe('/w-sibling/b.ts') + expect(toWorkdirRelative('rel/b.ts', '/w')).toBe('rel/b.ts') + // Normalization makes this land OUTSIDE the workdir → original path kept. + expect(toWorkdirRelative('/w/../up.ts', '/w')).toBe('/w/../up.ts') + }) + + it('previewLine keeps a within-budget line untouched', () => { + expect(previewLine('short', 100)).toBe('short') + }) + + it('formatGrepMatches groups by first-seen file order', () => { + const grouped = formatGrepMatches([ + { path: 'b.ts', lineNumber: 2, line: 'x' }, + { path: 'a.ts', lineNumber: 1, line: 'y' }, + { path: 'b.ts', lineNumber: 5, line: 'z' }, + ]) + expect(grouped).toBe('b.ts\nLine 2: x\nLine 5: z\n\na.ts\nLine 1: y') + }) +}) diff --git a/packages/fs/tool-fs-search/tsconfig.json b/packages/fs/tool-fs-search/tsconfig.json new file mode 100644 index 0000000000..9241aca15b --- /dev/null +++ b/packages/fs/tool-fs-search/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../util/retention" }, + { "path": "../../llm/llm" }, + { "path": "../../core/session" }, + { "path": "../../core/tools" }, + { "path": "../../core/system-prompt" }, + { "path": "../../bash/bash" }, + { "path": "../../spill/spill" } + ] +} diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index bf3633a253..79ebda87fb 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.) @@ -100,6 +100,6 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces ## Known Limitations and Deferred Work -- **No directory-listing, glob, grep, or search tools ship** — a deferral of [the tool-schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md); `ctx.fs.listDir` serves provider code such as skill discovery but still has no model-facing consumer, so models fall back to `bash`. +- **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies bash-backed `glob` and `grep` rather than extending the filesystem seam. - **`read` handles UTF-8 text files only** — binary-safe reads and PDF/image/multimodal content are deferred; a directory target is `FS_NOT_REGULAR_FILE`. - **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only (the deliberate [fs-family stance](../README.md)). 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 81fe63aaca..c95189912e 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 @@ -305,7 +305,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 }), @@ -318,14 +318,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/hook-protocol/tests/runner.spec.ts b/packages/hooks/hook-protocol/tests/runner.spec.ts index 5d3f7147f2..c2990e10be 100644 --- a/packages/hooks/hook-protocol/tests/runner.spec.ts +++ b/packages/hooks/hook-protocol/tests/runner.spec.ts @@ -22,6 +22,7 @@ function recordingBash(run: (spec: BashExecSpec) => Promise): { command: request.command, workdir: request.workdir ?? '/stub', timeoutMs: request.timeoutMs ?? 0, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, ...request.signal ? { signal: request.signal } : {}, ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, 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 5fb975b3dd..2a0444c422 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -466,9 +466,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 }] }] }) @@ -477,7 +477,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' }]) @@ -489,6 +494,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 () => { @@ -509,6 +521,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 6d2ac699eb..d262b51dbb 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -84,7 +84,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')]) @@ -92,7 +92,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) @@ -100,6 +105,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 () => { @@ -116,6 +128,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') }] }] }) @@ -393,7 +432,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/session-query/README.md b/packages/session-query/README.md index 8b0c06a30c..4c4b1c75c4 100644 --- a/packages/session-query/README.md +++ b/packages/session-query/README.md @@ -1,9 +1,9 @@ # session-query/ — session retrieval capability family -Trusted exact reads over live and durable session logs. Phase one contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, and bounded event reads. +Trusted exact reads and relationship traces over live and durable session logs. The family contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, bounded event reads, lineage, and direct event relationships. | Package | Role | ctx key | |---|---|---| -| [`session-query/`](session-query/README.md) | Logical-corpus and exact-event read service | `ctx.sessionQuery` | +| [`session-query/`](session-query/README.md) | Logical-corpus exact-read and relationship-tracing service | `ctx.sessionQuery` | -The family is independent of compaction: it reads the canonical session log but does not participate in compaction policy or execution. Full-text search remains proposed as a phase-two SQLite package rather than a speculative provider seam in this interface package. +The family is independent of compaction: it reads canonical lineage, surface operations, and logged provenance but does not participate in compaction policy or execution. Full-text search remains a proposed SQLite package rather than a speculative provider seam in this interface package. diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index f2293cbbee..269ba51e3e 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -1,16 +1,20 @@ # @deepseek-ai/dsh-session-query -Exact session-history retrieval through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`. +Exact session-history retrieval and relationship tracing through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`. ## Reads - `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order. - `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. - `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. +- `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. +- `traceEvent(request)` loads the logical log once and returns direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. -Persistence is optional and may mount or unmount dynamically. A cross-corpus list fails with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted exact reads list before loading, and reject a metadata mismatch rather than combining inconsistent observations. +Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. An event read or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. -`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. +`listEvents()` and `traceEvent()` run the same one-pass `dsh-session` surface fold. A loaded log is valid only when event seqs are zero-based and contiguous, surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name earlier events, and each positional replacement names and cites every surface node it removes; every violation fails with `SESSION_QUERY_INVALID_SURFACE`. + +`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. ## Configuration @@ -25,4 +29,4 @@ None, as this trusted query service returns cloned session records only to its c ## Known Limitations and Deferred Work - **No caller authorization** — this is trusted context-wide infrastructure; a future model tool or UI must constrain which sessions its caller may inspect. -- **Exact retrieval only** — filters, lineage/provenance traversal, extraction, search-provider protocol, index synchronization, and a model-facing tool are absent. Full-text search belongs beside its first implementation; the proposed SQLite package and its single transaction/reconciliation owner are described in the [phase-two RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). +- **No search or extraction** — filters, extraction registry, search-provider protocol, index synchronization, and a model-facing tool are absent. The [tracing decision](../../../docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md) owns relationship semantics; content-bearing full-text-search results and their chainable filters belong beside their first implementation in the proposed [SQLite package](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index e87327de13..ae2767598a 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-session-query", - "description": "Live-preferred exact session-history retrieval service (ctx.sessionQuery)", + "description": "Live-preferred exact session-history retrieval and tracing service (ctx.sessionQuery)", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts index 2736f68cbd..4a15366c2c 100644 --- a/packages/session-query/session-query/src/config.ts +++ b/packages/session-query/session-query/src/config.ts @@ -5,16 +5,17 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' /** Default maximum `before`/`after` raw-event window. */ export const SESSION_QUERY_READ_WINDOW_MAX = 50 -/** Configuration for exact session-query reads. */ +/** Configuration for exact session-query reads and traces. */ export interface Config { /** Maximum accepted raw read context on either side. Defaults to 50. */ readWindowMax?: number } -/** Stable machine-routable failure taxonomy for exact session reads. */ +/** Stable machine-routable failure taxonomy for exact session reads and traces. */ export type SessionQueryErrorCode = | 'SESSION_QUERY_EVENT_NOT_FOUND' | 'SESSION_QUERY_INVALID_CONFIG' + | 'SESSION_QUERY_INVALID_LINEAGE' | 'SESSION_QUERY_INVALID_SURFACE' | 'SESSION_QUERY_INVALID_WINDOW' | 'SESSION_QUERY_PERSISTENCE_FAILED' diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 828fe2ec88..bd35b51442 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -1,17 +1,19 @@ /** - * Exact session-history reads over live and optionally persisted logs. + * Exact session-history reads and traces over live and optionally persisted logs. * * @module @deepseek-ai/dsh-session-query */ import { Context, Service } from 'cordis' import z from 'schemastery' -import { foldSurface } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEventReadRequest, SessionEventRecord, + SessionEventTrace, + SessionEventTraceRequest, SessionEventWindow, + SessionLineageTrace, SessionRecord, } from './types.ts' import { @@ -20,6 +22,7 @@ import { type Config, } from './config.ts' import { SessionCorpus } from './corpus.ts' +import * as tracing from './tracing.ts' export type * from './types.ts' export type { Config, SessionQueryErrorCode } from './config.ts' @@ -31,7 +34,7 @@ declare module 'cordis' { } } -/** Live-preferred logical-corpus and exact-event read service. */ +/** Live-preferred logical-corpus exact-read and relationship-tracing service. */ export class SessionQueryService extends Service { static inject = ['sessions'] static Config: z = z.object({ @@ -68,7 +71,29 @@ export class SessionQueryService extends Service { */ async listEvents(sessionId: SessionId): Promise { const loaded = await this._corpus.load(sessionId) - return eventRecords(sessionId, loaded.events) + return tracing.eventRecords(sessionId, loaded.events) + } + + /** + * Trace known ancestry and descendants from one corpus observation. + * @param sessionId - logical session id to trace. + * @returns a complete lineage or an explicit unresolved parent boundary. + * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. + */ + async traceSession(sessionId: SessionId): Promise { + const records = await this._corpus.listSessions() + return tracing.traceSession(records, sessionId) + } + + /** + * Trace one event's direct positional and provenance relationships. + * @param request - target session id and event seq. + * @returns direct links plus the target's positional replacement chain. + * @throws when source resolution fails, the target is absent, or surface/provenance validation fails. + */ + async traceEvent(request: SessionEventTraceRequest): Promise { + const loaded = await this._corpus.load(request.sessionId) + return tracing.traceEvent(request.sessionId, loaded.events, request.seq) } /** @@ -110,27 +135,4 @@ export class SessionQueryService extends Service { } } -function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] { - let folded: ReturnType - try { - folded = foldSurface(events) - } catch (error: unknown) { - throw new SessionQueryError( - /* v8 ignore next -- foldSurface throws Error instances */ - `invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`, - 'SESSION_QUERY_INVALID_SURFACE', - { cause: error }, - ) - } - const current = new Set(folded.nodes.map(node => node.seq)) - const shadowed = new Set(folded.replacements.flatMap(replacement => replacement.shadowedSeqs)) - return events.map(event => ({ - sessionId, - seq: event.seq, - type: event.type, - time: event.time, - surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only', - })) -} - export default SessionQueryService diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts new file mode 100644 index 0000000000..2f422d6c26 --- /dev/null +++ b/packages/session-query/session-query/src/tracing.ts @@ -0,0 +1,222 @@ +/** One-shot session-lineage and event-relationship tracing helpers. */ + +import { foldSurface } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId, SurfaceEventType } from '@deepseek-ai/dsh-session' +import { SessionQueryError } from './config.ts' +import type { + SessionEventRecord, + SessionEventTrace, + SessionLineageNode, + SessionLineageTrace, + SessionRecord, +} from './types.ts' + +interface EventLogAnalysis { + records: SessionEventRecord[] + replacedBy: Map + replacedEventSeqs: Map +} + +/** + * Classify a raw event log with one canonical surface fold. + * @param sessionId - owner of the event log. + * @param events - detached raw event log. + * @returns lightweight records in ascending log order. + */ +export function eventRecords( + sessionId: SessionId, + events: readonly SessionEvent[], +): SessionEventRecord[] { + return analyzeEventLog(sessionId, events).records +} + +/** + * Trace one target after one canonical surface fold and whole-log validation. + * @param sessionId - owner of the event log. + * @param events - detached raw event log. + * @param seq - target event seq. + * @returns direct surface and provenance relationships. + */ +export function traceEvent( + sessionId: SessionId, + events: readonly SessionEvent[], + seq: number, +): SessionEventTrace { + const target = events[seq] + if (target === undefined || target.seq !== seq) { + throw new SessionQueryError( + `session "${sessionId}" has no event at seq ${seq}`, + 'SESSION_QUERY_EVENT_NOT_FOUND', + ) + } + + const analysis = analyzeEventLog(sessionId, events) + + const replacementChain: number[] = [] + let replacement = analysis.replacedBy.get(seq) + while (replacement !== undefined) { + replacementChain.push(replacement) + replacement = analysis.replacedBy.get(replacement) + } + + const derivedEventSeqs: number[] = [] + for (const event of events) { + if (event.seq <= seq) continue + if (eventSources(event).includes(seq)) derivedEventSeqs.push(event.seq) + } + + // The target check above proves the parallel record exists at this index. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const targetRecord = analysis.records[seq]! + const replacedBy = analysis.replacedBy.get(seq) + return { + target: targetRecord, + ...replacedBy === undefined ? {} : { replacedBy }, + replacementChain, + replacedEventSeqs: analysis.replacedEventSeqs.get(seq) ?? [], + sourceEventSeqs: [...eventSources(target)], + derivedEventSeqs, + } +} + +/** + * Trace one target's known ancestry and recursively known descendants. + * @param records - complete logical corpus from one observation. + * @param sessionId - target session id. + * @returns complete or explicitly partial lineage. + */ +export function traceSession( + records: readonly SessionRecord[], + sessionId: SessionId, +): SessionLineageTrace { + const byId = new Map(records.map(record => [record.header.id, record])) + const target = byId.get(sessionId) + if (target === undefined) { + throw new SessionQueryError( + `session "${sessionId}" not found`, + 'SESSION_QUERY_SESSION_NOT_FOUND', + ) + } + + const ancestors: SessionRecord[] = [] + const ancestrySeen = new Set([sessionId]) + let unresolvedParentId: SessionId | undefined + let parentId = target.header.parentSession + while (parentId !== undefined) { + if (ancestrySeen.has(parentId)) { + throw new SessionQueryError( + `session lineage contains a cycle at "${parentId}"`, + 'SESSION_QUERY_INVALID_LINEAGE', + ) + } + ancestrySeen.add(parentId) + const parent = byId.get(parentId) + if (parent === undefined) { + unresolvedParentId = parentId + break + } + ancestors.push(parent) + parentId = parent.header.parentSession + } + + const childrenByParent = new Map() + for (const record of records) { + const parent = record.header.parentSession + if (parent === undefined) continue + const children = childrenByParent.get(parent) ?? [] + children.push(record) + childrenByParent.set(parent, children) + } + for (const children of childrenByParent.values()) { + children.sort((a, b) => a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id)) + } + + const descendants = buildDescendants(childrenByParent, sessionId) + const common = { + target: cloneRecord(target), + ancestors: ancestors.map(cloneRecord), + descendants, + } + if (unresolvedParentId !== undefined) { + return { ...common, complete: false, unresolvedParentId } + } + return { + ...common, + complete: true, + root: cloneRecord(ancestors.at(-1) ?? target), + } +} + +function analyzeEventLog( + sessionId: SessionId, + events: readonly SessionEvent[], +): EventLogAnalysis { + let folded: ReturnType + try { + folded = foldSurface(events) + } catch (error: unknown) { + throw new SessionQueryError( + /* v8 ignore next -- foldSurface throws Error instances */ + `invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`, + 'SESSION_QUERY_INVALID_SURFACE', + { cause: error }, + ) + } + const current = new Set(folded.nodes.map(node => node.seq)) + const replacedBy = new Map() + const replacedEventSeqs = new Map() + for (const replacement of folded.replacements) { + const removed = replacement.shadowedSeqs + replacedEventSeqs.set(replacement.seq, removed) + for (const removedSeq of removed) { + replacedBy.set(removedSeq, replacement.seq) + } + } + return { + records: events.map(event => ({ + sessionId, + seq: event.seq, + type: event.type, + time: event.time, + surface: current.has(event.seq) + ? 'current' + : replacedBy.has(event.seq) ? 'shadowed' : 'log-only', + })), + replacedBy, + replacedEventSeqs, + } +} + +function eventSources(event: SessionEvent): readonly number[] { + return (event as SessionEvent).sourceEventSeqs ?? [] +} + +function buildDescendants( + childrenByParent: ReadonlyMap, + sessionId: SessionId, +): SessionLineageNode[] { + const descendants: SessionLineageNode[] = [] + const stack = [{ sessionId, descendants }] + while (stack.length > 0) { + // The length guard proves a frame exists. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const frame = stack.pop()! + const nodes: SessionLineageNode[] = [] + for (const child of childrenByParent.get(frame.sessionId) ?? []) { + const node = { session: cloneRecord(child), descendants: [] } + nodes.push(node) + frame.descendants.push(node) + } + for (let index = nodes.length - 1; index >= 0; index -= 1) { + // The loop bounds prove this indexed node exists. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const node = nodes[index]! + stack.push({ sessionId: node.session.header.id, descendants: node.descendants }) + } + } + return descendants +} + +function cloneRecord(record: SessionRecord): SessionRecord { + return { ...record, header: structuredClone(record.header) } +} diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index 5c49695dda..38f0225ee4 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -1,5 +1,6 @@ /** - * Public records for exact reads over the live-preferred logical session corpus. + * Public records for exact reads and relationship traces over the + * live-preferred logical session corpus. * * @module @deepseek-ai/dsh-session-query/types */ @@ -33,6 +34,61 @@ export interface SessionEventRecord { surface: SessionEventSurface } +/** Recursive descendant node in a session-lineage trace. */ +export interface SessionLineageNode { + /** Detached logical-corpus record for this descendant. */ + session: SessionRecord + /** Direct children, each carrying its own recursive descendants. */ + descendants: SessionLineageNode[] +} + +/** Known ancestry and descendants for one logical session. */ +export type SessionLineageTrace = { + /** Detached record for the session that was traced. */ + target: SessionRecord + /** Known parents from the immediate parent outward. */ + ancestors: SessionRecord[] + /** Complete known descendant trees rooted at the target's direct children. */ + descendants: SessionLineageNode[] +} & ( + | { + /** The complete parent chain is present in the logical corpus. */ + complete: true + /** Detached record at the top of the complete lineage. */ + root: SessionRecord + } + | { + /** The parent chain leaves the visible logical corpus. */ + complete: false + /** First parent id that is not present in the logical corpus. */ + unresolvedParentId: SessionId + } +) + +/** Request for direct surface and provenance relationships around one event. */ +export interface SessionEventTraceRequest { + /** Session that owns the target event. */ + sessionId: SessionId + /** Target event seq. */ + seq: number +} + +/** Direct surface and provenance relationships for one event. */ +export interface SessionEventTrace { + /** Lightweight target record. */ + target: SessionEventRecord + /** Immediate positional replacement event, when the target was shadowed. */ + replacedBy?: number + /** Positional replacers from the immediate replacement to the final replacement. */ + replacementChain: number[] + /** Surface nodes directly removed when the target itself performed a replacement. */ + replacedEventSeqs: number[] + /** Direct logged provenance sources in their recorded order. */ + sourceEventSeqs: number[] + /** Later events that directly name the target as a provenance source, in log order. */ + derivedEventSeqs: number[] +} + /** Request for one event plus raw neighboring log context. */ export interface SessionEventReadRequest { /** Session that owns the target event. */ diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 3b50feee45..bb7d01ec73 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -110,7 +110,7 @@ describe('session-query exact reads', () => { session.append( 'assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] }, - { surfaceOp: { op: 'replace', start: first.seq, end: first.seq } }, + { surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] }, ) expect((await ctx.sessionQuery.listEvents(session.id)).map(record => record.surface)) @@ -227,11 +227,13 @@ describe('session-query exact reads', () => { it('turns malformed surfaces and direct invalid config into typed errors', async () => { const ctx = await liveContext() const session = ctx.sessions.create(SessionId('bad-surface')) - session.append( - 'assistant/message', - { turn: 1, step: 1, content: [] }, - { surfaceOp: { op: 'replace', start: 9, end: 9 } }, - ) + ;(session as unknown as { log: SessionEvent[] }).log.push({ + type: 'assistant/message', + seq: 0, + time: 1, + data: { turn: 1, step: 1, content: [] }, + surfaceOp: { op: 'replace', start: 9, end: 9 }, + }) await expect(ctx.sessionQuery.listEvents(session.id)) .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts new file mode 100644 index 0000000000..eb22a9f95c --- /dev/null +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -0,0 +1,422 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' +import SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import SessionQueryService, { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' + +type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] } + +/** Test-only mutable view used to verify detached returned metadata. */ +function mutableHeader(value: SessionHeader): MutableSessionHeader { + return value +} + +function header(id: string, createdAt = 1, extra: Partial = {}): SessionHeader { + return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra } +} + +function appendEvent(seq: number, sources?: number[]): SessionEvent { + return { + type: 'user/message', + seq, + time: seq + 1, + data: { content: [{ type: 'text', text: `event ${seq}` }], source: { kind: 'user' } }, + surfaceOp: 'append', + ...sources === undefined ? {} : { sourceEventSeqs: sources }, + } +} + +class TracePersistence extends SessionPersistence { + static entries = new Map() + static listCalls = 0 + static loadCalls = 0 + static listFailure: Error | undefined + static loadFailure: Error | undefined + static afterList: (() => void) | undefined + + static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { + this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) + this.listCalls = 0 + this.loadCalls = 0 + this.listFailure = undefined + this.loadFailure = undefined + this.afterList = undefined + } + + create(meta: SessionHeader): Promise { + TracePersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] }) + return Promise.resolve() + } + + append(id: SessionIdType, events: readonly SessionEvent[]): Promise { + const entry = TracePersistence.entries.get(id) + if (entry === undefined) return Promise.reject(new Error('missing test session')) + entry.events.push(...structuredClone(events)) + return Promise.resolve() + } + + load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + TracePersistence.loadCalls += 1 + if (TracePersistence.loadFailure !== undefined) return Promise.reject(TracePersistence.loadFailure) + const entry = TracePersistence.entries.get(id) + if (entry === undefined) return Promise.reject(new Error('missing test session')) + return Promise.resolve(structuredClone(entry)) + } + + list(): Promise { + TracePersistence.listCalls += 1 + if (TracePersistence.listFailure !== undefined) return Promise.reject(TracePersistence.listFailure) + const result = [...TracePersistence.entries.values()].map(entry => structuredClone(entry.meta)) + TracePersistence.afterList?.() + return Promise.resolve(result) + } +} + +async function queryContext(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionQueryService) + return ctx +} + +function expectCode(code: SessionQueryErrorCode): Error { + return expect.objectContaining({ code }) as Error +} + +function appendTraceEvents(session: Session): void { + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'draft' }, + }) + session.append( + 'user/message', + { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, + { surfaceOp: 'append', sourceEventSeqs: [0] }, + ) + session.append( + 'assistant/message', + { turn: 1, step: 1, content: [{ type: 'text', text: 'summary one' }] }, + { surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1, 0] }, + ) + session.append( + 'context/message', + { content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } }, + { surfaceOp: 'append' }, + ) + session.append( + 'assistant/message', + { turn: 1, step: 2, content: [{ type: 'text', text: 'summary two' }] }, + { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [0, 2] }, + ) +} + +describe('session lineage tracing', () => { + it('returns complete ancestry, deterministic descendant trees, and detached records', async () => { + const ctx = await queryContext() + const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 0 } }) + const parent = ctx.sessions.create(SessionId('parent'), { + meta: { createdAt: 1, parentSession: root.id }, + }) + const target = ctx.sessions.create(SessionId('target'), { + meta: { createdAt: 2, parentSession: parent.id }, + }) + ctx.sessions.create(SessionId('b'), { meta: { createdAt: 4, parentSession: target.id } }) + const childA = ctx.sessions.create(SessionId('a'), { + meta: { createdAt: 4, parentSession: target.id }, + }) + ctx.sessions.create(SessionId('older'), { meta: { createdAt: 3, parentSession: target.id } }) + ctx.sessions.create(SessionId('grandchild'), { + meta: { createdAt: 5, parentSession: childA.id }, + }) + + const trace = await ctx.sessionQuery.traceSession(target.id) + expect(trace.complete).toBe(true) + if (!trace.complete) throw new Error('expected complete lineage') + expect(trace.ancestors.map(record => record.header.id)).toEqual([parent.id, root.id]) + expect(trace.root.header.id).toBe(root.id) + expect(trace.descendants.map(node => node.session.header.id)) + .toEqual([SessionId('older'), SessionId('a'), SessionId('b')]) + expect(trace.descendants[1]?.descendants.map(node => node.session.header.id)) + .toEqual([SessionId('grandchild')]) + + mutableHeader(trace.target.header).createdAt = 99 + mutableHeader(trace.ancestors[0]!.header).createdAt = 99 + mutableHeader(trace.root.header).createdAt = 99 + mutableHeader(trace.descendants[0]!.session.header).createdAt = 99 + const repeated = await ctx.sessionQuery.traceSession(target.id) + expect(repeated.target.header.createdAt).toBe(2) + expect(repeated.ancestors[0]?.header.createdAt).toBe(1) + expect(repeated.descendants[0]?.session.header.createdAt).toBe(3) + }) + + it('represents root and unresolved-parent traces explicitly', async () => { + const ctx = await queryContext() + const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 1 } }) + const partial = ctx.sessions.create(SessionId('partial'), { + meta: { createdAt: 2, parentSession: SessionId('outside') }, + }) + + await expect(ctx.sessionQuery.traceSession(root.id)).resolves.toMatchObject({ + complete: true, + root: { header: { id: root.id } }, + ancestors: [], + }) + await expect(ctx.sessionQuery.traceSession(partial.id)).resolves.toMatchObject({ + complete: false, + unresolvedParentId: SessionId('outside'), + ancestors: [], + }) + }) + + it('rejects target-connected cycles and missing targets', async () => { + const ctx = await queryContext() + ctx.sessions.create(SessionId('a'), { + meta: { createdAt: 1, parentSession: SessionId('b') }, + }) + ctx.sessions.create(SessionId('b'), { + meta: { createdAt: 2, parentSession: SessionId('a') }, + }) + + await expect(ctx.sessionQuery.traceSession(SessionId('a'))) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LINEAGE')) + await expect(ctx.sessionQuery.traceSession(SessionId('missing'))) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + }) + + it('uses one cross-corpus observation and preserves persistence failure semantics', async () => { + const durable = header('durable') + TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) + const ctx = await queryContext() + await ctx.plugin(TracePersistence) + + await expect(ctx.sessionQuery.traceSession(durable.id)).resolves.toMatchObject({ + target: { live: false, persisted: true }, + complete: true, + }) + expect(TracePersistence.listCalls).toBe(1) + expect(TracePersistence.loadCalls).toBe(0) + + TracePersistence.listFailure = new Error('unavailable') + await expect(ctx.sessionQuery.traceSession(durable.id)) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + }) + + it('constructs deeply nested descendants without consuming the JavaScript call stack', async () => { + const ctx = await queryContext() + const root = ctx.sessions.create(SessionId('deep-0'), { meta: { createdAt: 0 } }) + let parent = root + for (let depth = 1; depth < 3_000; depth += 1) { + parent = ctx.sessions.create(SessionId(`deep-${depth}`), { + meta: { createdAt: depth, parentSession: parent.id }, + }) + } + + const trace = await ctx.sessionQuery.traceSession(root.id) + expect(trace.complete).toBe(true) + let node = trace.descendants[0] + for (let depth = 1; depth < 3_000; depth += 1) { + if (node === undefined) throw new Error(`lineage ended before depth ${depth}`) + if (depth === 2_999) expect(node.session.header.id).toBe(SessionId('deep-2999')) + node = node.descendants[0] + } + expect(node).toBeUndefined() + }) +}) + +describe('session event tracing', () => { + it('returns direct replacement and provenance links in their contract order', async () => { + const ctx = await queryContext() + const session = ctx.sessions.create(SessionId('trace')) + appendTraceEvents(session) + + const original = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 1 }) + expect(original.target).toMatchObject({ + sessionId: session.id, + seq: 1, + type: 'user/message', + surface: 'shadowed', + }) + expect(original).toMatchObject({ + replacedBy: 2, + replacementChain: [2, 4], + replacedEventSeqs: [], + sourceEventSeqs: [0], + derivedEventSeqs: [2], + }) + await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 })) + .resolves.toMatchObject({ + replacedBy: 4, + replacementChain: [4], + replacedEventSeqs: [1], + sourceEventSeqs: [1, 0], + derivedEventSeqs: [4], + }) + await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 0 })) + .resolves.toMatchObject({ + target: { surface: 'log-only' }, + replacementChain: [], + sourceEventSeqs: [], + derivedEventSeqs: [1, 2, 4], + }) + await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 })) + .resolves.toMatchObject({ + replacementChain: [], + replacedEventSeqs: [2], + sourceEventSeqs: [0, 2], + derivedEventSeqs: [], + }) + }) + + it('returns fresh trace arrays and target records', async () => { + const ctx = await queryContext() + const session = ctx.sessions.create(SessionId('detached')) + appendTraceEvents(session) + + const first = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 }) + first.target.time = -1 + first.replacementChain.push(99) + first.replacedEventSeqs.push(99) + first.sourceEventSeqs.push(99) + first.derivedEventSeqs.push(99) + const repeated = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 }) + expect(repeated.target.time).not.toBe(-1) + expect(repeated.replacementChain).toEqual([4]) + expect(repeated.replacedEventSeqs).toEqual([1]) + expect(repeated.sourceEventSeqs).toEqual([1, 0]) + expect(repeated.derivedEventSeqs).toEqual([4]) + }) + + it('loads persisted logs once, prefers live logs, and preserves failures and conflicts', async () => { + const durable = header('shared', 1, { cwd: '/same' }) + TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) + const ctx = await queryContext() + await ctx.plugin(TracePersistence) + + await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + .resolves.toMatchObject({ target: { type: 'user/message', surface: 'current' } }) + expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1]) + + const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } }) + live.append( + 'context/message', + { content: [{ type: 'text', text: 'live' }], source: { kind: 'plugin', plugin: 'test' } }, + { surfaceOp: 'append' }, + ) + TracePersistence.listFailure = new Error('list unavailable') + TracePersistence.loadFailure = new Error('load unavailable') + await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + .resolves.toMatchObject({ target: { type: 'context/message' } }) + expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1]) + + TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) + const failedCtx = await queryContext() + await failedCtx.plugin(TracePersistence) + TracePersistence.listFailure = new Error('list unavailable') + await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TracePersistence.listFailure = undefined + TracePersistence.loadFailure = new Error('load unavailable') + await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TracePersistence.loadFailure = undefined + TracePersistence.afterList = () => { + mutableHeader(TracePersistence.entries.get(durable.id)!.meta).cwd = '/changed' + } + await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + .rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) + }) + + it('checks target existence before surface or provenance analysis', async () => { + const bad = header('bad-target') + const malformed: SessionEvent[] = [appendEvent(0), { + type: 'assistant/message', + seq: 1, + time: 2, + data: { turn: 1, step: 1, content: [] }, + surfaceOp: { op: 'replace', start: 9, end: 9 }, + sourceEventSeqs: [], + }] + TracePersistence.reset([{ meta: bad, events: malformed }]) + const ctx = await queryContext() + await ctx.plugin(TracePersistence) + + await expect(ctx.sessionQuery.traceEvent({ sessionId: bad.id, seq: 9 })) + .rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) + await expect(ctx.sessionQuery.traceEvent({ sessionId: bad.id, seq: 0 })) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) + }) + + it.each([ + ['non-surface sources', [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, sourceEventSeqs: [0] }, + ]], + ['invalid source array', [ + { ...appendEvent(0), sourceEventSeqs: 'invalid' }, + ]], + ['empty sources', [ + appendEvent(0, []), + ]], + ['sparse sources', [ + appendEvent(0, Array(1)), + ]], + ['duplicate sources', [ + appendEvent(0), + appendEvent(1, [0, 0]), + ]], + ['missing earlier source', [ + appendEvent(0), + appendEvent(1, [-1]), + ]], + ['future source', [ + appendEvent(0, [1]), + appendEvent(1), + ]], + ['replacement without sources', [ + appendEvent(0), + { ...appendEvent(1), surfaceOp: { op: 'replace', start: 0, end: 0 } }, + ]], + ['replacement missing a shadowed source', [ + { type: 'assistant/chunk', seq: 0, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'draft' } } }, + appendEvent(1), + { ...appendEvent(2, [0]), surfaceOp: { op: 'replace', start: 1, end: 1 } }, + ]], + ] as const)('rejects an invalid surface log: %s', async (_name, rawEvents) => { + const durable = header('invalid-provenance') + const events = structuredClone(rawEvents) as unknown as SessionEvent[] + TracePersistence.reset([{ meta: durable, events }]) + const ctx = await queryContext() + await ctx.plugin(TracePersistence) + + await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) + }) + + it('rejects surfaceOp on a non-surface event as an invalid surface', async () => { + const durable = header('invalid-non-surface-op') + const events = [{ + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + surfaceOp: 'append', + }] as unknown as SessionEvent[] + TracePersistence.reset([{ meta: durable, events }]) + const ctx = await queryContext() + await ctx.plugin(TracePersistence) + + await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) + }) + + it('applies the same surface contract to listEvents', async () => { + const durable = header('list-regression') + TracePersistence.reset([{ meta: durable, events: [appendEvent(0), appendEvent(1, [0, 0])] }]) + const ctx = await queryContext() + await ctx.plugin(TracePersistence) + + await expect(ctx.sessionQuery.listEvents(durable.id)) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) + }) +}) 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/spill/README.md b/packages/spill/README.md new file mode 100644 index 0000000000..7d54c91eb5 --- /dev/null +++ b/packages/spill/README.md @@ -0,0 +1,13 @@ +# spill/ - spill storage capability family + +The tool-output spill capability seam: an abstract storage interface, a local filesystem implementation, and the tool-result policy that uses it. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `spill/` | Abstract spill storage seam (`saveText` — persist oversized tool text and return a locator + retrieval hint) | `ctx.spillStore` | +| `spill-local/` | Local-filesystem backend: private, session-scoped files with traversal-safe names | (registers on `ctx.spillStore`) | +| `spill-policy/` | `tools/post-execute` policy: replaces oversized plain-text results with a preview + spill locator | (no service surface) | + +The interface lives at `spill/spill/`. The split mirrors bash/fs: the seam owns storage only, `spill-local` owns the filesystem mechanics, and `spill-policy` owns WHEN to spill and the model-facing notice. Preview mechanics stay in [`util/retention`](../util/README.md) — the policy composes the two without either owning the other's job. + +See the [tool output spill RFC](../../docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design rationale, including why final-result spill is separate from tool-owned early spill (bash streams, subagent rollouts) and why creation belongs to the runtime spill seam rather than the model-facing `write` tool. diff --git a/packages/spill/spill-local/README.md b/packages/spill/spill-local/README.md new file mode 100644 index 0000000000..59d23b8a46 --- /dev/null +++ b/packages/spill/spill-local/README.md @@ -0,0 +1,28 @@ +# @deepseek-ai/dsh-spill-local + +The **local-filesystem** implementation of the [`@deepseek-ai/dsh-spill`](../spill) storage seam. Registers as `ctx.spillStore` and persists a tool's oversized text to a private, session-scoped file; its locator is the file path and its retrieval hint tells the model to use `read` or `grep` on that path. + +## Storage layout + +Files land at `/session-/​-`: + +- **`root`** — the config `root` (resolved to absolute), or a lazily-created private (0700) per-process directory under the OS temp dir when omitted. A predictable, world-readable root would let other local users read spilled tool output or plant symlinks. +- **`session-`** — a short `sha256(sessionId)` prefix, so a session's spill files group together and a future cleanup can drop them per session. +- **`-`** — an unpredictable hex prefix (defeats symlink planting in a shared root) plus the caller's `suggestedName` sanitized to one safe path segment (traversal-proof; mirrors the JSONL persistence backend's `encodeSegment`). The write is exclusive + owner-only (`open(path, 'wx', 0o600)`): it fails on any pre-existing path, symlink or not, so a planted target cannot redirect it. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `root` | private 0700 temp dir | Root directory for spill files. Set to keep them under a known location. | + +`saveText` rejects on a real storage failure (permissions, ENOSPC); the spill policy treats a rejection as best-effort and keeps the inline result. See the seam README for the vocabulary and the [tool output spill RFC](../../../docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design. + +## Model Experience + +Indirectly, through spill consumers that render the local path and `read`/`grep` retrieval guidance. + +## Known Limitations and Deferred Work + +- **Local spill files persist until external cleanup** — the backend has no session-lifecycle deletion or age-based retention policy, because persisted, resumed, and forked sessions may still reference a path. +- **Locators require a co-located filesystem consumer** — a remote or virtual deployment needs another `SpillStore` backend whose locator and retrieval hint are meaningful there. diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json new file mode 100644 index 0000000000..a75c4bfc0b --- /dev/null +++ b/packages/spill/spill-local/package.json @@ -0,0 +1,38 @@ +{ + "name": "@deepseek-ai/dsh-spill-local", + "description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-spill": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-spill": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/spill/spill-local/src/index.ts b/packages/spill/spill-local/src/index.ts new file mode 100644 index 0000000000..73e2cad851 --- /dev/null +++ b/packages/spill/spill-local/src/index.ts @@ -0,0 +1,65 @@ +/** + * `LocalSpillStore`: the host-filesystem implementation of the + * `@deepseek-ai/dsh-spill` storage seam. Persists a tool's oversized text to a + * private, session-scoped file (see `./store.ts` for the traversal-safe naming + * and exclusive owner-only write) and returns a path locator plus local + * read/grep retrieval guidance. + * + * @module @deepseek-ai/dsh-spill-local + */ + +import { Context } from 'cordis' +import { resolve } from 'node:path' +import z from 'schemastery' +import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' +import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' +import { privateRoot, saveTextFile } from './store.ts' + +export { encodeSegment, privateRoot, saveTextFile, sessionDir } from './store.ts' +export type { SavedText, SaveTextOptions } from './store.ts' + +/** Plugin config (all optional — `static Config` supplies the defaults). */ +export interface Config { + /** + * Root directory for spill files. Omitted uses a lazily-created private + * (0700) per-process directory under the OS temp dir — the safe default for + * a local deployment. Set it to keep spill files under a known location. + */ + root?: string +} + +/** + * Local-filesystem spill backend. Files land under `/session-/…` + * with unpredictable names, an exclusive owner-only (0600) write, and a private + * (0700) root — a spilled tool result must not be readable by other local users + * or redirectable via a planted symlink. + */ +export class LocalSpillStore extends SpillStore { + static Config: z = z.object({ + root: z.string(), + }) + + /** Resolved absolute spill root (config `root`, else the private default), fixed at construction. */ + readonly root: string + + constructor(ctx: Context, config: Config) { + super(ctx) + this.root = config.root !== undefined ? resolve(config.root) : privateRoot() + } + + async saveText(input: SaveTextSpill): Promise { + const saved = await saveTextFile({ + root: this.root, + sessionId: input.owner.sessionId, + suggestedName: input.suggestedName, + content: input.content, + }) + return { + locator: SpillLocator(saved.path), + bytes: saved.bytes, + retrievalHint: 'Use read with offset/limit, or grep this path to search within it.', + } + } +} + +export default LocalSpillStore diff --git a/packages/spill/spill-local/src/store.ts b/packages/spill/spill-local/src/store.ts new file mode 100644 index 0000000000..e44418767a --- /dev/null +++ b/packages/spill/spill-local/src/store.ts @@ -0,0 +1,120 @@ +/** + * Cordis-free storage mechanics for the local spill backend: private + * session-scoped directory selection, safe-name derivation, path-traversal + * protection, and the exclusive owner-only write. Kept out of the service class + * (like `dsh-bash-local`'s `run.ts`) so the filesystem behavior is unit-testable + * without a `ctx` and without the OS temp dir. + * + * @module @deepseek-ai/dsh-spill-local/store + */ + +import { createHash, randomBytes } from 'node:crypto' +import { mkdtempSync } from 'node:fs' +import { mkdir, open } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +let defaultRoot: string | undefined + +/** + * The default spill root: a private (0700) per-process directory under the OS + * tmpdir, created lazily. Predictable world-readable paths would let other + * local users read spilled tool output or pre-create symlinks; `mkdtemp` gives + * an unpredictable suffix and 0700 semantics. + * + * @returns The lazily-created private spill root. + */ +export function privateRoot(): string { + defaultRoot ??= mkdtempSync(join(tmpdir(), 'dsh-spill-')) + return defaultRoot +} + +// Deliberately mirrors the JSONL path encoder, but keeps spill's empty-name +// policy (`""` -> `"~"`) local so storage backends stay decoupled. +/* jscpd:ignore-start */ +/** + * Encode an arbitrary string as one safe path segment, injectively over ALL JS + * (UTF-16) strings. A session id / suggested name is untrusted input, so this + * neutralizes `../`, absolute paths, NUL, and separators before any filesystem + * use. Each code unit is kept literal (`[A-Za-z0-9._-]`, minus `~`) or escaped + * as `~XXXX`; `~` is itself escaped, so the mapping is reversible and distinct + * inputs never collide. The whole-segment tokens `.`/`..` are escaped so they + * can never traverse. An empty string encodes to `~` (never an empty segment). + * (Mirrors the JSONL persistence backend's `encodeSegment`.) + * + * @param raw The untrusted string to encode as one safe path segment. + * @returns An injective, filesystem-safe single path segment. + */ +export function encodeSegment(raw: string): string { + if (raw.length === 0) return '~' + if (raw === '.') return '~002E' + if (raw === '..') return '~002E~002E' + let out = '' + for (let i = 0; i < raw.length; i++) { + const code = raw.charCodeAt(i) + const ch = String.fromCharCode(code) + if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) { + out += ch + } else { + out += '~' + code.toString(16).toUpperCase().padStart(4, '0') + } + } + return out +} +/* jscpd:ignore-end */ + +/** + * The session-scoped directory: `/session-`, a short stable hash. + * + * @param root The spill root directory. + * @param sessionId The owning session id to hash into a stable directory name. + * @returns The absolute session-scoped spill directory path. + */ +export function sessionDir(root: string, sessionId: string): string { + const hash = createHash('sha256').update(sessionId).digest('hex').slice(0, 12) + return join(root, `session-${hash}`) +} + +/** Options for {@link saveTextFile} — the resolved root and the request fields the store needs. */ +export interface SaveTextOptions { + /** The spill root directory (configured or the lazy private default). */ + root: string + /** The owning session id (scopes the directory). */ + sessionId: string + /** Caller-suggested base name; sanitized to one safe segment before use. */ + suggestedName: string + /** The full text to persist. */ + content: string +} + +/** A written spill file. */ +export interface SavedText { + path: string + bytes: number +} + +/** + * Write `content` to a fresh file under the session-scoped directory and return + * its path + byte length. The filename is a random hex prefix plus the + * sanitized `suggestedName`, so it is unpredictable (defeats symlink planting in + * a shared root) AND stays readable. The open is exclusive + owner-only + * (`'wx', 0o600`): it fails on any existing path — symlink or not — so a + * pre-planted target cannot redirect the write. + * + * @param options The resolved root and request fields required to save the file. + * @returns The written file path and UTF-8 byte length. + */ +export async function saveTextFile(options: SaveTextOptions): Promise { + const dir = sessionDir(options.root, options.sessionId) + await mkdir(dir, { recursive: true, mode: 0o700 }) + const safeName = encodeSegment(options.suggestedName) + const path = join(dir, `${randomBytes(6).toString('hex')}-${safeName}`) + const bytes = Buffer.byteLength(options.content, 'utf8') + const handle = await open(path, 'wx', 0o600) + try { + await handle.writeFile(options.content) + } finally { + await handle.close() + } + return { path, bytes } +} diff --git a/packages/spill/spill-local/tests/spill-local.spec.ts b/packages/spill/spill-local/tests/spill-local.spec.ts new file mode 100644 index 0000000000..d73fca9fe3 --- /dev/null +++ b/packages/spill/spill-local/tests/spill-local.spec.ts @@ -0,0 +1,139 @@ +/** + * Tests for the LOCAL spill backend: `saveText` writes a session-scoped file and + * returns a locator + byte length + retrieval hint, filename sanitization + * neutralizes traversal, the configured `root` is honored (and the private + * default when omitted), and a storage failure rejects. The Cordis-free + * `store.ts` helpers are exercised directly for the naming/encoding edge cases. + */ + +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { Context } from 'cordis' +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join } from 'node:path' +import { CallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SaveTextSpill } from '@deepseek-ai/dsh-spill' +import LocalSpillStore, { encodeSegment, privateRoot, saveTextFile, sessionDir } from '@deepseek-ai/dsh-spill-local' + +let root: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'dsh-spill-test-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function request(overrides: Partial = {}): SaveTextSpill { + return { + owner: { sessionId: SessionId('sess-1') }, + source: { toolName: 'web_fetch', callId: CallId('call-1'), label: 'result' }, + suggestedName: 'web_fetch.txt', + content: 'the full body', + ...overrides, + } +} + +describe('encodeSegment', () => { + it('keeps the safe set literal', () => { + expect(encodeSegment('web_fetch.txt')).toBe('web_fetch.txt') + expect(encodeSegment('a-B_9.z')).toBe('a-B_9.z') + }) + + it('escapes separators and tilde (dots are literal except as whole-segment tokens)', () => { + // `.` is in the safe set, so `..` inside a longer string stays literal; the + // traversal defense is that separators escape, keeping the result ONE segment. + expect(encodeSegment('../etc/passwd')).toBe('..~002Fetc~002Fpasswd') + expect(encodeSegment('a/b')).toBe('a~002Fb') + expect(encodeSegment('~')).toBe('~007E') + }) + + it('escapes the whole-segment dot tokens', () => { + expect(encodeSegment('.')).toBe('~002E') + expect(encodeSegment('..')).toBe('~002E~002E') + }) + + it('encodes the empty string to a non-empty segment', () => { + expect(encodeSegment('')).toBe('~') + }) +}) + +describe('sessionDir', () => { + it('is a stable per-session hash under the root', () => { + const dir = sessionDir('/spill', 'sess-1') + expect(dir).toBe(sessionDir('/spill', 'sess-1')) + expect(dir).toMatch(/\/spill\/session-[0-9a-f]{12}$/) + expect(sessionDir('/spill', 'sess-2')).not.toBe(dir) + }) +}) + +describe('saveTextFile', () => { + it('writes the content under the session dir and reports bytes', async () => { + const saved = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: 'r.txt', content: 'héllo' }) + expect(readFileSync(saved.path, 'utf8')).toBe('héllo') + expect(saved.bytes).toBe(Buffer.byteLength('héllo', 'utf8')) + expect(dirname(saved.path)).toBe(sessionDir(root, 'sess-1')) + expect(saved.path).toMatch(/\/[0-9a-f]{12}-r\.txt$/) + }) + + it('sanitizes a traversal-shaped suggested name into one segment', async () => { + const saved = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: '../../evil', content: 'x' }) + // The separators escaped, so the whole name is one leaf under the session dir. + expect(dirname(saved.path)).toBe(sessionDir(root, 'sess-1')) + expect(saved.path.includes('/..')).toBe(false) + }) + + it('creates the session dir with owner-only permissions', async () => { + const saved = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: 'r.txt', content: 'x' }) + // 0o700 dir, 0o600 file (masked by umask, but the owner bits must hold). + expect(statSync(dirname(saved.path)).mode & 0o700).toBe(0o700) + expect(statSync(saved.path).mode & 0o600).toBe(0o600) + }) + + it('gives distinct paths to two saves of the same name', async () => { + const a = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: 'r.txt', content: 'a' }) + const b = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: 'r.txt', content: 'b' }) + expect(a.path).not.toBe(b.path) + }) +}) + +describe('privateRoot', () => { + it('is a stable absolute directory under the temp dir', () => { + const first = privateRoot() + expect(isAbsolute(first)).toBe(true) + expect(privateRoot()).toBe(first) + }) +}) + +describe('LocalSpillStore service', () => { + it('registers as ctx.spillStore and saves under the configured root', async () => { + const ctx = new Context() + await ctx.plugin(LocalSpillStore, { root }) + const ref = await ctx.spillStore.saveText(request()) + expect(dirname(ref.locator)).toBe(sessionDir(root, 'sess-1')) + expect(readFileSync(ref.locator, 'utf8')).toBe('the full body') + expect(ref.bytes).toBe(Buffer.byteLength('the full body', 'utf8')) + expect(ref.retrievalHint).toBe('Use read with offset/limit, or grep this path to search within it.') + }) + + it('resolves a relative configured root to absolute', async () => { + const ctx = new Context() + await ctx.plugin(LocalSpillStore, { root: '.' }) + expect(isAbsolute((ctx.spillStore as LocalSpillStore).root)).toBe(true) + }) + + it('falls back to the private root when none is configured', async () => { + const ctx = new Context() + await ctx.plugin(LocalSpillStore, {}) + expect((ctx.spillStore as LocalSpillStore).root).toBe(privateRoot()) + }) + + it('rejects when the root is not writable (missing parent, exclusive open)', async () => { + const ctx = new Context() + // A file (not a dir) as the root makes mkdir under it fail — a real storage error. + const filePath = (await saveTextFile({ root, sessionId: 's', suggestedName: 'f', content: 'x' })).path + await ctx.plugin(LocalSpillStore, { root: filePath }) + await expect(ctx.spillStore.saveText(request())).rejects.toThrow() + }) +}) diff --git a/packages/spill/spill-local/tsconfig.json b/packages/spill/spill-local/tsconfig.json new file mode 100644 index 0000000000..8e818212f5 --- /dev/null +++ b/packages/spill/spill-local/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../spill" } + ] +} diff --git a/packages/spill/spill-policy/README.md b/packages/spill/spill-policy/README.md new file mode 100644 index 0000000000..bb20ac9dfc --- /dev/null +++ b/packages/spill/spill-policy/README.md @@ -0,0 +1,46 @@ +# @deepseek-ai/dsh-spill-policy + +The **tool-result spill policy**: a `tools/post-execute` transformer that keeps oversized plain-text tool results out of the model's context. When a final result exceeds `maxInlineBytes`, it saves the FULL text through [`ctx.spillStore`](../spill) and replaces the model-facing result with a bounded head/tail preview plus the backend's locator and retrieval hint. + +This plugin registers **no service** and owns no storage or preview mechanics: preview is [`@deepseek-ai/dsh-retention`](../../util/retention) (`TextRetainer`), storage is `ctx.spillStore`. It only decides WHEN to spill and composes the notice. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `maxInlineBytes` | *(omitted)* | Model-facing context cap for a plain-text result, in UTF-8 bytes (a non-negative integer; validated at load). **Omitted disables the policy entirely** (the plugin registers nothing). When set, a larger result is spilled and replaced with a preview derived from the same budget (head/tail split). | + +## Behavior + +1. Let the tool run (delegates via `next()`, so it bounds whatever a downstream hook accepted). +2. Skip `read` (avoids a `read → spill → read again` loop) and any non-`accept` decision (a `block`'s corrective feedback passes through). +3. Flatten the accepted content only when it is **plain text** (all `text` blocks); a result with any non-text block is left untouched. +4. If its UTF-8 size is `≤ maxInlineBytes`, leave it unchanged. +5. Otherwise save the full text and replace the result with a preview + this notice, sized so the whole replacement (preview + blank line + notice) stays within `maxInlineBytes` — the notice's byte cost is reserved out of the budget, so the preview shrinks to fit and the model-facing result never exceeds the cap: + + ```text + + + (Omitted N bytes. Full formatted result stored at: /…/session-…/…-web_fetch.txt. Use read with offset/limit, or grep this path to search within it.) + ``` + + When the notice alone fills the budget (a tiny cap or a long locator) the preview is empty and only the notice is returned. If even that notice-only replacement would exceed `maxInlineBytes`, the policy keeps the inline result — it never emits a replacement over the cap (and a within-cap replacement is always smaller than the original, so this also means spilling never adds bytes). + +**Best-effort:** no session owner, no `ctx.spillStore` backend, or a `saveText` rejection ⇒ the policy logs a warning and returns the original result. A spill failure never turns a successful call into an `isError` or hides the inline result. + +## Scope + +The policy sees only the FINAL formatted tool result — not a tool's internal resource. If a provider already truncated (e.g. `web-fetch-local.maxBodyChars`), the spill artifact holds the full formatted result the tool returned, not the full original source. Provider/resource caps stay mandatory and separate. Tool-owned early spill (bash streams, subagent rollouts) is future work — see the [tool output spill RFC](../../../docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md). + +## Model Experience + +### Oversized plain-text result + +**What the model sees**: Results at or below `maxInlineBytes`, `read` results, blocked decisions, and results containing non-text blocks are unchanged. An oversized plain-text result becomes a bounded head/tail preview followed by `(Omitted bytes. Full formatted result stored at: . )`; storage or ownership failures leave the original result visible. + +**Token effect**: A successful replacement is at most `maxInlineBytes` UTF-8 bytes and remains in history until compaction; the full spill text is not resent to the model. + +## Known Limitations and Deferred Work + +- **Only final plain-text results are spillable** — mixed-content results, blocked feedback, and `read` pass through; provider truncation or tool-owned retention that happened earlier cannot be recovered here. +- **A notice that cannot fit disables replacement for that call** — a tiny cap or long locator leaves the oversized original inline after the backend has already saved an unreferenced spill. diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json new file mode 100644 index 0000000000..9c28ea5382 --- /dev/null +++ b/packages/spill/spill-policy/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-spill-policy", + "description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service surface)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-retention": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-spill": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-retention": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-spill": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/spill/spill-policy/src/index.ts b/packages/spill/spill-policy/src/index.ts new file mode 100644 index 0000000000..b7ac4a31fc --- /dev/null +++ b/packages/spill/spill-policy/src/index.ts @@ -0,0 +1,174 @@ +/** + * The spill-policy PLUGIN: a `tools/post-execute` result transformer that keeps + * oversized plain-text tool results out of the model's context. When a final + * result's UTF-8 size exceeds `maxInlineBytes`, it saves the FULL text to a + * session-scoped spill artifact (`ctx.spillStore`) and replaces the + * model-facing result with a bounded head/tail preview plus the backend's + * locator and retrieval guidance. + * + * It registers NO service and owns NO storage or preview mechanics: preview is + * `@deepseek-ai/dsh-retention` (`TextRetainer`), storage is `ctx.spillStore`. + * The policy only decides WHEN to spill and composes the notice. + * + * ## Deliberately narrow + * + * - Omitted `maxInlineBytes` ⇒ the plugin registers nothing (a true no-op). + * - Plain-text results only: a result carrying any non-text block is left + * untouched (the policy knows only the final formatted text, not tool + * internals). + * - `read` is skipped to avoid a `read → spill → read again` loop. + * - Best-effort: no session owner, no `ctx.spillStore` backend, or a save + * failure ⇒ log and return the original result. A spill failure must NEVER + * turn a successful tool call into an `isError` or hide the inline result. + * + * It COMPOSES with other post-execute listeners: it delegates via `next()` and + * bounds the resulting `accept` content, so a hook that replaced the content + * still has its replacement bounded, and a `block` decision passes through + * unchanged. + * + * @module @deepseek-ai/dsh-spill-policy + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { TextRetainer, describeOmitted } from '@deepseek-ai/dsh-retention' +import type { Omitted } from '@deepseek-ai/dsh-retention' +import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' +import type { SpillPolicyExec } from './types.ts' + +export type { SpillPolicyExec } from './types.ts' + +/** Plugin config. */ +export interface Config { + /** + * The model-facing context cap for a plain-text tool result, in UTF-8 bytes. + * Omitted disables the policy entirely (no-op). When set, a result larger than + * this is spilled and replaced with a preview derived from this same budget. + */ + maxInlineBytes?: number +} + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'spill-policy' + +/** Require the tool registry (its `tools/post-execute` waterfall is the seam we transform). */ +export const inject = ['tools'] + +export const Config: z = z.object({ + maxInlineBytes: z.number(), +}) + +/** All-text content flattened to one UTF-8 string, or `undefined` if any block is non-text. */ +function flattenPlainText(content: ContentBlock[]): string | undefined { + let text = '' + for (const block of content) { + if (block.type !== 'text') return undefined + text += block.text + } + return text +} + +/** The owning session id, or `undefined` for a call with no agent (a direct/test call). */ +function ownerSessionId(exec: ToolExecution): SessionId | undefined { + return (exec as SpillPolicyExec).agent?.session.header.id +} + +/** Build the bounded head/tail preview for `text`, splitting `budget` bytes across the two ends. */ +function preview(text: string, budget: number): { text: string; omitted: Omitted } { + const headBytes = Math.ceil(budget / 2) + const tailBytes = Math.floor(budget / 2) + const retainer = new TextRetainer({ kind: 'headTail', headBytes, tailBytes }) + retainer.push(text) + const kept = retainer.finish() + return { text: kept.text, omitted: kept.omittedBytes } +} + +/** The spill-notice line for a given omission + saved reference (no preview, no leading blank line). */ +function spillNotice(omitted: Omitted, ref: SpillRef): string { + const omission = describeOmitted(omitted, 'bytes') + return `(${omission} Full formatted result stored at: ${ref.locator}. ${ref.retrievalHint})` +} + +export function apply(ctx: Context, config: Config): void { + const maxInlineBytes = config.maxInlineBytes + // Omitted ⇒ no automatic spill policy: register nothing at all. + if (maxInlineBytes === undefined) return + // Validate at LOAD, not per call: a negative/fractional cap would reach + // TextRetainer's assertBudget and throw, turning every oversized-result call + // into an isError. A bad config must fail the deployment, not the tool. + if (!Number.isInteger(maxInlineBytes) || maxInlineBytes < 0) { + throw new Error(`spill-policy: maxInlineBytes must be a non-negative integer (got ${maxInlineBytes})`) + } + + ctx.on('tools/post-execute', async (exec, result, next): Promise => { + // Delegate first so a downstream listener (e.g. a hook) settles the result; + // we bound whatever it accepted. A block passes through — spill only shapes + // accepted plain-text results, never corrective feedback. + const decision = await next() + // Skip `read` to avoid a read → spill → read again loop. + if (decision.kind !== 'accept' || exec.name === 'read') return decision + + const content = decision.content ?? result.content + const text = flattenPlainText(content) + if (text === undefined) return decision + const totalBytes = Buffer.byteLength(text, 'utf8') + if (totalBytes <= maxInlineBytes) return decision + + const sessionId = ownerSessionId(exec) + if (sessionId === undefined) { + ctx.logger.warn(`spill-policy: no session owner for ${exec.name} result; keeping the inline result`) + return decision + } + const spillStore = ctx.get('spillStore') + if (!spillStore) { + ctx.logger.warn('spill-policy: no ctx.spillStore backend loaded; keeping the inline result') + return decision + } + + const save: SaveTextSpill = { + owner: { sessionId }, + source: { toolName: exec.name, callId: exec.callId, label: 'result' }, + suggestedName: `${exec.name}.txt`, + content: text, + } + let ref: SpillRef + try { + ref = await spillStore.saveText(save) + } catch (error: unknown) { + // Best-effort: a storage failure (permissions, ENOSPC, backend down) must + // never fail the call or hide the result — keep the original inline. + ctx.logger.warn(`spill-policy: saveText failed for ${exec.name}: ${String(error)}; keeping the inline result`) + return decision + } + + // Reserve the notice's byte cost INSIDE maxInlineBytes so the replacement + // (preview + blank line + notice) never exceeds the documented cap — a naive + // preview that spent the whole budget then appended the notice could be + // larger than the cap, and for a marginally-over result even larger than the + // original. The reservation uses a notice priced at the worst-case omission + // count (the full byte total): its digit count bounds the real count's, so + // the reserved size is a safe upper bound and the final notice is never + // longer than what we reserved. `\n\n` is the 2-byte join. + const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2 + const previewBudget = Math.max(0, maxInlineBytes - reserve) + const { text: previewText, omitted } = preview(text, previewBudget) + const notice = spillNotice(omitted, ref) + const replacedText = previewText.length > 0 ? `${previewText}\n\n${notice}` : notice + // Invariant: the policy NEVER emits a replacement larger than the cap. When + // the notice alone exceeds maxInlineBytes (a tiny cap or a long spill root), + // there is no within-cap replacement, so keep the inline result — spilling + // would break the advertised context cap. (A within-cap replacement is + // always smaller than the original, which is > cap by the entry condition, + // so this one check subsumes "not smaller than the original" too. The spill + // file already written is a harmless orphan; cleanup is deferred.) + if (Buffer.byteLength(replacedText, 'utf8') > maxInlineBytes) { + ctx.logger.warn(`spill-policy: spill notice for ${exec.name} exceeds maxInlineBytes; keeping the inline result`) + return decision + } + const replaced: ContentBlock[] = [{ type: 'text', text: replacedText }] + return { kind: 'accept', content: replaced, ...decision.additionalContexts ? { additionalContexts: decision.additionalContexts } : {} } + }) +} diff --git a/packages/spill/spill-policy/src/types.ts b/packages/spill/spill-policy/src/types.ts new file mode 100644 index 0000000000..3046e3efe5 --- /dev/null +++ b/packages/spill/spill-policy/src/types.ts @@ -0,0 +1,26 @@ +/** + * Vocabulary for the spill-policy plugin: the minimal structural view of a tool + * execution the policy needs to derive the owning session for a spill artifact. + * + * `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies this shape, so the policy + * reads `exec` straight through without importing `dsh-tools` or `dsh-agent`. + * Only the session HEADER id is read — the same identity every other subsystem + * keys off (see `dsh-tool-bash`'s owner derivation). + * + * @module @deepseek-ai/dsh-spill-policy/types + */ + +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** Minimal structural view of a tool execution: the owning session's header id, when present. */ +export interface SpillPolicyExec { + /** The agent on whose behalf the call runs, when there is one. */ + agent?: { + session: { + header: { + /** The canonical session identity — the spill owner. */ + id: SessionId + } + } + } +} diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts new file mode 100644 index 0000000000..2449f26a8c --- /dev/null +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -0,0 +1,276 @@ +/** + * Tests for the spill-policy PLUGIN. It registers no service, only the + * `tools/post-execute` transformer. We drive real tools through + * `ctx.tools.execute(...)` and assert: disabled mode is a true no-op, an + * oversized plain-text result is spilled and replaced with a preview + locator, + * a small result and a non-text result pass through, `read` is skipped, and a + * `saveText` failure / missing backend / missing owner all preserve the original + * result without an `isError`. + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' +import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' +import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy' + +/** A stub spill backend recording its saves; `fail` exercises the best-effort fallback. */ +class StubStore extends SpillStore { + saves: SaveTextSpill[] = [] + fail = false + + async saveText(input: SaveTextSpill): Promise { + if (this.fail) throw new Error('disk full') + this.saves.push(input) + return { + locator: SpillLocator(`/spill/${input.suggestedName}`), + bytes: Buffer.byteLength(input.content, 'utf8'), + retrievalHint: 'Use the stub retrieval path.', + } + } +} + +/** A tool returning `text` verbatim (name configurable so we can register `read`). */ +function textTool(name: string, text: string) { + return defineTool({ + name, + description: name, + parameters: {}, + async execute(): Promise { return [{ type: 'text', text }] }, + }) +} + +/** A minimal exec carrying a session header id (the spill owner). */ +function exec(name: string, session = 's1'): ToolExecution { + // Only agent.session.header.id is read by the policy; a structural stub suffices. + const agent = { session: { header: { id: SessionId(session) } } } + return { callId: CallId(`call-${name}`), name, arguments: {}, agent } as unknown as ToolExecution +} + +/** + * Build a context with tools + the policy, and optionally a spill backend. + * Returns the context and the backend handle (undefined when `withSpill` false). + */ +async function setup(config: SpillPolicy.Config, withSpill = true): Promise<{ ctx: Context; spill?: StubStore; fiber: Awaited> }> { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + let spill: StubStore | undefined + if (withSpill) { + await ctx.plugin(StubStore) + spill = ctx.spillStore as StubStore + } + const fiber = await ctx.plugin(SpillPolicy, config) + return { ctx, fiber, ...spill ? { spill } : {} } +} + +/** Flatten a result's text blocks. */ +function textOf(content: ContentBlock[]): string { + return content.filter((b): b is Extract => b.type === 'text').map(b => b.text).join('') +} + +describe('disabled mode', () => { + it('registers no post-execute listener when maxInlineBytes is omitted', async () => { + const { ctx, spill } = await setup({}) + ctx.tools.register(textTool('big', 'x'.repeat(1000))) + const result = await ctx.tools.execute(exec('big')) + expect(textOf(result.content)).toBe('x'.repeat(1000)) + expect(result.isError).toBe(false) + expect(spill?.saves).toHaveLength(0) + }) +}) + +describe('loader export shape', () => { + it('has no default export and keeps name/inject/Config through unwrapExports', () => { + expect('default' in SpillPolicy).toBe(false) + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(SpillPolicy) as Record + expect(unwrapped).toBe(SpillPolicy) + expect(unwrapped.name).toBe('spill-policy') + expect(unwrapped.inject).toEqual(['tools']) + expect(unwrapped.Config).toBeDefined() + expect(typeof unwrapped.apply).toBe('function') + }) +}) + +describe('config validation', () => { + it('rejects a negative maxInlineBytes at load', async () => { + await expect(setup({ maxInlineBytes: -1 })).rejects.toThrow(/non-negative integer/) + }) + + it('rejects a fractional maxInlineBytes at load', async () => { + await expect(setup({ maxInlineBytes: 1.5 })).rejects.toThrow(/non-negative integer/) + }) +}) + +describe('oversized plain-text replacement', () => { + it('spills the full text and replaces the result with a preview + locator within the cap', async () => { + const { ctx, spill } = await setup({ maxInlineBytes: 200 }) + const body = 'HEAD'.repeat(200) + 'TAIL'.repeat(200) // 1600 bytes > 200 + ctx.tools.register(textTool('big', body)) + const result = await ctx.tools.execute(exec('big')) + + expect(result.isError).toBe(false) + expect(spill?.saves).toHaveLength(1) + expect(spill?.saves[0]?.content).toBe(body) + expect(spill?.saves[0]?.source.toolName).toBe('big') + expect(spill?.saves[0]?.suggestedName).toBe('big.txt') + expect(spill?.saves[0]?.owner.sessionId).toBe('s1') + + const text = textOf(result.content) + expect(text).not.toBe(body) + expect(text.startsWith('HEAD')).toBe(true) + expect(text).toContain('Full formatted result stored at: /spill/big.txt') + expect(text).toContain('Use the stub retrieval path.') + expect(text).toContain('Omitted') + // The replacement (preview + blank line + notice) stays within the cap and + // is smaller than the original — the whole point of spilling. + expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(200) + expect(Buffer.byteLength(text, 'utf8')).toBeLessThan(body.length) + }) + + it('keeps the inline result when the notice-only replacement would exceed the cap', async () => { + // A body just over a tiny cap: the notice alone is larger than the cap, so + // there is no within-cap replacement — the policy keeps the inline result. + const { ctx } = await setup({ maxInlineBytes: 4 }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const body = 'xxxxx' // 5 bytes > 4, but far shorter than the notice + ctx.tools.register(textTool('big', body)) + const result = await ctx.tools.execute(exec('big')) + expect(textOf(result.content)).toBe(body) + expect(warn).toHaveBeenCalled() + }) + + it('leaves a small plain-text result unchanged', async () => { + const { ctx, spill } = await setup({ maxInlineBytes: 1000 }) + ctx.tools.register(textTool('small', 'tiny')) + const result = await ctx.tools.execute(exec('small')) + expect(textOf(result.content)).toBe('tiny') + expect(spill?.saves).toHaveLength(0) + }) + + it('leaves a result with a non-text block unchanged', async () => { + const { ctx, spill } = await setup({ maxInlineBytes: 5 }) + ctx.tools.register(defineTool({ + name: 'mixed', + description: 'mixed', + parameters: {}, + async execute(): Promise { + return [{ type: 'text', text: 'x'.repeat(100) }, { type: 'reasoning', text: 'why' }] + }, + })) + const result = await ctx.tools.execute(exec('mixed')) + expect(spill?.saves).toHaveLength(0) + expect(result.content).toHaveLength(2) + }) +}) + +describe('read skip', () => { + it('never spills the read tool result (avoids a read → spill → read loop)', async () => { + const { ctx, spill } = await setup({ maxInlineBytes: 10 }) + ctx.tools.register(textTool('read', 'x'.repeat(1000))) + const result = await ctx.tools.execute(exec('read')) + expect(textOf(result.content)).toBe('x'.repeat(1000)) + expect(spill?.saves).toHaveLength(0) + }) +}) + +describe('best-effort fallback', () => { + it('keeps the original result when saveText fails', async () => { + const { ctx, spill } = await setup({ maxInlineBytes: 10 }) + spill!.fail = true + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + ctx.tools.register(textTool('big', 'x'.repeat(1000))) + const result = await ctx.tools.execute(exec('big')) + expect(textOf(result.content)).toBe('x'.repeat(1000)) + expect(result.isError).toBe(false) + expect(warn).toHaveBeenCalled() + }) + + it('keeps the original result when no spill backend is loaded', async () => { + const { ctx } = await setup({ maxInlineBytes: 10 }, false) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + ctx.tools.register(textTool('big', 'x'.repeat(1000))) + const result = await ctx.tools.execute(exec('big')) + expect(textOf(result.content)).toBe('x'.repeat(1000)) + expect(warn).toHaveBeenCalled() + }) + + it('keeps the original result when the call has no session owner', async () => { + const { ctx, spill } = await setup({ maxInlineBytes: 10 }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + ctx.tools.register(textTool('big', 'x'.repeat(1000))) + const result = await ctx.tools.execute({ callId: CallId('c'), name: 'big', arguments: {} }) + expect(textOf(result.content)).toBe('x'.repeat(1000)) + expect(spill?.saves).toHaveLength(0) + expect(warn).toHaveBeenCalled() + }) +}) + +describe('composition', () => { + it('bounds content a downstream post-execute listener replaced', async () => { + const { ctx, spill } = await setup({ maxInlineBytes: 200 }) + // A later-registered listener replaces the (small) tool result with a big one; + // the policy delegated via next(), so it bounds the replacement. + ctx.on('tools/post-execute', async (_e, _r, _next) => + ({ kind: 'accept', content: [{ type: 'text', text: 'z'.repeat(500) }] })) + ctx.tools.register(textTool('small', 'tiny')) + const result = await ctx.tools.execute(exec('small')) + expect(spill?.saves[0]?.content).toBe('z'.repeat(500)) + expect(textOf(result.content)).toContain('Full formatted result stored at') + }) + + it('preserves downstream accept-decision contexts when spilling', async () => { + const { ctx } = await setup({ maxInlineBytes: 200 }) + const context = { content: [{ type: 'text' as const, text: 'note' }], source: { kind: 'plugin' as const, plugin: 'test' } } + ctx.on('tools/post-execute', async (_e, _r, _next) => + ({ kind: 'accept', additionalContexts: [context] })) + ctx.tools.register(textTool('big', 'x'.repeat(1000))) + const result = await ctx.tools.execute(exec('big')) + expect(textOf(result.content)).toContain('Full formatted result stored at') + expect(result.additionalContexts).toEqual([context]) + }) +}) + +describe('cap invariant', () => { + it('keeps the inline result when the notice alone exceeds the cap, even for a large original', async () => { + // A large body (so it is well over the cap) but a cap smaller than the + // notice itself: there is no within-cap replacement, so the policy must keep + // the inline result rather than emit content over maxInlineBytes. + const { ctx } = await setup({ maxInlineBytes: 8 }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const body = 'x'.repeat(5000) + ctx.tools.register(textTool('big', body)) + const result = await ctx.tools.execute(exec('big')) + expect(textOf(result.content)).toBe(body) + expect(warn).toHaveBeenCalled() + }) +}) + +describe('disposal (HMR safety)', () => { + it('stops transforming oversized results after the plugin fiber is disposed', async () => { + const { ctx, spill, fiber } = await setup({ maxInlineBytes: 200 }) + const body = 'HEAD'.repeat(200) + 'TAIL'.repeat(200) + ctx.tools.register(textTool('big', body)) + + // Live: the listener spills and replaces. + const before = await ctx.tools.execute(exec('big')) + expect(textOf(before.content)).toContain('Full formatted result stored at') + expect(spill?.saves).toHaveLength(1) + + // After disposal the listener is gone — the result passes through untouched + // and nothing more is spilled (no leaked registration across reload). + await fiber.dispose() + const after = await ctx.tools.execute(exec('big')) + expect(textOf(after.content)).toBe(body) + expect(spill?.saves).toHaveLength(1) + }) +}) diff --git a/packages/spill/spill-policy/tsconfig.json b/packages/spill/spill-policy/tsconfig.json new file mode 100644 index 0000000000..6a81ab2f3c --- /dev/null +++ b/packages/spill/spill-policy/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../util/retention" }, + { "path": "../../llm/llm" }, + { "path": "../../core/session" }, + { "path": "../spill" }, + { "path": "../../core/tools" } + ] +} diff --git a/packages/spill/spill/README.md b/packages/spill/spill/README.md new file mode 100644 index 0000000000..c80277339b --- /dev/null +++ b/packages/spill/spill/README.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-spill + +The **spill storage seam**: an abstract `SpillStore` service (`ctx.spillStore`) defining WHAT a spill backend does — persist a tool's oversized text and return a model-facing locator plus retrieval guidance — without saying HOW. + +This package is one third of the spill capability, split so each concern evolves (and swaps) independently: + +| Package | Role | +|---|---| +| `@deepseek-ai/dsh-spill` (this) | the interface: abstract service + vocabulary types | +| `@deepseek-ai/dsh-spill-local` | an implementation: private session-scoped files on the host filesystem | +| `@deepseek-ai/dsh-spill-policy` | the tool-result policy that spills oversized final results | + +The split mirrors the bash/fs seams. A future remote or virtual backend (e.g. a `spill://…` URI, a database key, or a backend-specific retrieval tool) implements this interface without touching the policy plugin. + +## Service API (`ctx.spillStore`) + +| Member | Semantics | +|---|---| +| `saveText(input)` | Persist `input.content` verbatim; resolves with a `SpillRef` (opaque locator, exact bytes written, and retrieval hint). **Rejects on a real storage failure** (permissions, ENOSPC, backend unavailable) — the caller decides how to degrade. | + +Storage is grouped by the request's `owner` session as a save-time namespace; the backend chooses its own private representation and may derive names from — never trust as a path — the caller's `suggestedName`. The seam owns storage only: NO retention policy (that is [`@deepseek-ai/dsh-retention`](../../util/retention)), NO tool-result replacement (that is `@deepseek-ai/dsh-spill-policy`), NO retrieval/search API (the backend's `retrievalHint` tells the model what to do with the locator). + +## Vocabulary + +`SaveTextSpill` (owner, source, suggestedName, content) is the request; `SpillRef` (locator, bytes, retrievalHint) is the result. `SpillLocator` is [branded](../../util/brand) and rendered to the model as an opaque string — a local path for `dsh-spill-local`, but a future backend may return a URI, key, or command token without changing policy/tool consumers. `SpillOwner.sessionId` is the save-time storage namespace: forked sessions inherit existing locators from the seeded log without copying or re-owning them, and new spills after the fork use the child session id. `SpillSource` (toolName, callId, label) is descriptive provenance for backend naming and inspection, not access control. See `src/types.ts` for the full contracts. + +See the [tool output spill RFC](../../../docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design rationale, including why creation belongs to the runtime spill seam rather than the model-facing `write` tool. + +## Model Experience + +Indirectly, through spill consumers that render a backend locator and retrieval guidance. + +## Known Limitations and Deferred Work + +- **The seam has no retrieval or deletion API** — consumers can only render the backend's locator and guidance; lifecycle and access semantics remain backend-specific. +- **Storage is not access control** — `SpillOwner` namespaces writes but does not authorize reads of a locator; each backend and retrieval consumer must enforce its own boundary. diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json new file mode 100644 index 0000000000..3103c9cd11 --- /dev/null +++ b/packages/spill/spill/package.json @@ -0,0 +1,36 @@ +{ + "name": "@deepseek-ai/dsh-spill", + "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/spill/spill/src/index.ts b/packages/spill/spill/src/index.ts new file mode 100644 index 0000000000..4c8826defb --- /dev/null +++ b/packages/spill/spill/src/index.ts @@ -0,0 +1,58 @@ +/** + * The spill storage seam (`ctx.spillStore`): an abstract service defining WHAT a + * spill backend does — persist a tool's oversized text and return a model-facing + * locator plus retrieval guidance — without saying HOW. Implementations + * subclass {@link SpillStore} and register as the `spillStore` service; + * `@deepseek-ai/dsh-spill-local` (host filesystem) is the first. + * + * The seam is deliberately minimal: `saveText` and nothing else. It owns NO + * retention policy (that is `@deepseek-ai/dsh-retention`), NO tool-result + * replacement (that is `@deepseek-ai/dsh-spill-policy`), and NO retrieval or + * search API. The backend supplies the locator and retrieval hint appropriate + * for its storage substrate. + * + * @module @deepseek-ai/dsh-spill + */ + +import { Context, Service } from 'cordis' +import type { SaveTextSpill, SpillRef } from './types.ts' + +export { SpillLocator } from './types.ts' +export type { SaveTextSpill, SpillOwner, SpillRef, SpillSource } from './types.ts' + +declare module 'cordis' { + interface Context { + spillStore: SpillStore + } +} + +/** + * Abstract spill storage service. Subclass, implement {@link saveText}, and load + * the subclass as a plugin — it registers as `ctx.spillStore` (one + * implementation per context; loading a second throws, cordis' standard + * duplicate-service behavior). + * + * Semantics every implementation must honor: + * - {@link saveText} persists the FULL `content` verbatim and returns an opaque + * locator, exact byte length, and model-facing retrieval guidance. + * - Storage is scoped by the request's {@link SaveTextSpill.owner} session; the + * backend chooses a private (not world-readable) location and a collision-free + * name derived from — never equal to — the caller's `suggestedName`. + * - `saveText` REJECTS on a real storage failure (permissions, ENOSPC, backend + * unavailable); the caller decides how to degrade (the spill policy treats a + * rejection as best-effort and keeps the inline result). + */ +export abstract class SpillStore extends Service { + constructor(ctx: Context) { + super(ctx, 'spillStore') + } + + /** + * Persist `input.content` to a session-scoped spill artifact. + * @param input - the owner, provenance, suggested name, and full text to save. + * @returns the saved artifact's {@link SpillRef}; rejects on a storage failure. + */ + abstract saveText(input: SaveTextSpill): Promise +} + +export default SpillStore diff --git a/packages/spill/spill/src/types.ts b/packages/spill/spill/src/types.ts new file mode 100644 index 0000000000..96376bb268 --- /dev/null +++ b/packages/spill/spill/src/types.ts @@ -0,0 +1,73 @@ +/** + * Vocabulary for the spill storage seam. Types only — the abstract service + * lives in `./index.ts`, implementations in sibling packages + * (`@deepseek-ai/dsh-spill-local` first). + * + * @module @deepseek-ai/dsh-spill/types + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { CallId } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** + * Opaque model-facing handle for one spilled artifact. A local backend may use a + * filesystem path; a remote or database backend may use a URI or key. Consumers + * render it with {@link SpillRef.retrievalHint}, but do not parse it. + */ +export type SpillLocator = Branded<'SpillLocator'> + +/** + * Brand a string as a {@link SpillLocator}. + * + * @param locator The backend-produced locator string to brand. + * @returns The branded spill locator. + */ +export function SpillLocator(locator: string): SpillLocator { + return locator as SpillLocator +} + +/** + * Save-time storage namespace for a spilled artifact. The session id lets a + * backend group storage under the producing session, but the returned + * {@link SpillLocator} is the model-facing handle. Forked sessions inherit + * locators already present in the seeded log; those artifacts are not copied or + * re-owned, and spills produced after the fork use the child session id. + */ +export interface SpillOwner { + sessionId: SessionId +} + +/** + * Provenance of one spilled artifact — recorded by the backend for a readable + * filename and inspection. Not interpreted for access control; purely + * descriptive. + */ +export interface SpillSource { + /** The tool whose result was spilled (e.g. `web_fetch`). */ + toolName: string + /** The model-issued call id the result belongs to. */ + callId: CallId + /** A short human label for the artifact (e.g. `result`). */ + label: string +} + +/** One request to persist text to a spill artifact. */ +export interface SaveTextSpill { + owner: SpillOwner + source: SpillSource + /** + * A caller-suggested base name (e.g. `web_fetch.txt`). The backend sanitizes + * it to a single safe path segment before use — it is a hint, never a path. + */ + suggestedName: string + /** The full text to persist (UTF-8). */ + content: string +} + +/** A saved spill artifact: its locator, byte length, and backend-specific retrieval guidance. */ +export interface SpillRef { + locator: SpillLocator + bytes: number + retrievalHint: string +} diff --git a/packages/spill/spill/tests/service.spec.ts b/packages/spill/spill/tests/service.spec.ts new file mode 100644 index 0000000000..ddbc4086e1 --- /dev/null +++ b/packages/spill/spill/tests/service.spec.ts @@ -0,0 +1,60 @@ +/** + * Tests for the spill seam INTERFACE: a minimal concrete subclass registers as + * `ctx.spillStore`, a second load throws (duplicate service), and disposal + * releases the service. The storage behavior is the implementation's concern + * (`@deepseek-ai/dsh-spill-local`); here we only pin the seam contract. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' +import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' + +/** Minimal concrete backend: records the last request, returns a fixed ref. */ +class StubStore extends SpillStore { + last: SaveTextSpill | undefined + + async saveText(input: SaveTextSpill): Promise { + this.last = input + return { + locator: SpillLocator(`/stub/${input.suggestedName}`), + bytes: Buffer.byteLength(input.content, 'utf8'), + retrievalHint: 'Use the stub reader.', + } + } +} + +function request(content: string): SaveTextSpill { + return { + owner: { sessionId: SessionId('s1') }, + source: { toolName: 'web_fetch', callId: CallId('c1'), label: 'result' }, + suggestedName: 'web_fetch.txt', + content, + } +} + +describe('spill seam', () => { + it('registers as ctx.spillStore and saves text', async () => { + const ctx = new Context() + await ctx.plugin(StubStore) + const ref = await ctx.spillStore.saveText(request('hello')) + expect(ref).toEqual({ locator: '/stub/web_fetch.txt', bytes: 5, retrievalHint: 'Use the stub reader.' }) + expect((ctx.spillStore as StubStore).last?.content).toBe('hello') + }) + + it('rejects a second implementation (one per context)', async () => { + const ctx = new Context() + await ctx.plugin(StubStore) + await expect(ctx.plugin(StubStore)).rejects.toThrow() + }) + + it('releases the service on disposal', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(StubStore) + expect(ctx.spillStore).toBeInstanceOf(StubStore) + await fiber.dispose() + expect((ctx as Context & { spillStore?: unknown }).spillStore).toBeUndefined() + }) +}) diff --git a/packages/spill/spill/tsconfig.json b/packages/spill/spill/tsconfig.json new file mode 100644 index 0000000000..0c2fd5c57f --- /dev/null +++ b/packages/spill/spill/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../util/brand" }, + { "path": "../../llm/llm" }, + { "path": "../../core/session" } + ] +} diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 3bae0ba279..b35f2af42a 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -168,6 +168,9 @@ export interface RunOptions { export async function runScenario(input: InputScript, opts: RunOptions): Promise { const cwd = await mkdtemp(join(tmpdir(), 'acp-snap-cwd-')) const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-')) + // Fixed path length: spill-policy budgets the preview against the REAL path + // before stdout normalization, so tmpdir() length differences churn goldens. + const spillRoot = '/tmp/dsh-acp-snapshot-spill' // Everything past the temp-dir creation runs under a try/finally that always // removes both dirs — so a failure in workspace seeding, spawn, or any step // never leaks them (the "e2e tests own their resources" rule). @@ -187,6 +190,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise DSH_SNAPSHOT: opts.mode, DSH_SNAPSHOT_FILE: opts.fixtureFile, DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, + DSH_SNAPSHOT_SPILL_ROOT: spillRoot, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {}, @@ -291,6 +295,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise } await rm(cwd, { recursive: true, force: true }) await rm(sessionsRoot, { recursive: true, force: true }) + await rm(spillRoot, { recursive: true, force: true }) } return { diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index e3f32792ab..591053798e 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -14,6 +14,16 @@ const MESSAGE_PREFIX = '{{messagePrefix}}' /** A UUID v4 string, the shape `randomUUID()` produces for session ids. */ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi +const LOCAL_SPILL_PATH_RE = new RegExp( + String.raw`\{\{cwd\}\}/\.spill/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)` + + String.raw`(?=\. Use read with offset/limit|[\s)]|$)`, + 'g', +) +const SNAPSHOT_SPILL_PATH_RE = new RegExp( + String.raw`/tmp/dsh-acp-snapshot-spill/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)` + + String.raw`(?=\. Use read with offset/limit|[\s)]|$)`, + 'g', +) /** Inputs the normalizers need to recognize a run's volatile values. */ export interface NormalizeContext { @@ -29,6 +39,9 @@ function scrubString(value: string, ctx: NormalizeContext): string { // cwd first (longest, most specific), then explicit session ids, then any // residual UUID (covers ids that appear in places we didn't enumerate). out = out.split(ctx.cwd).join(CWD) + out = out.split(`/private${CWD}`).join(CWD) + out = out.replace(LOCAL_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`) + out = out.replace(SNAPSHOT_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`) for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID) out = out.replace(UUID_RE, SESSION_ID) return out diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index d6c95045f6..f9992e82dd 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -93,6 +93,52 @@ describe('normalizeSessionLog', () => { expect(out).not.toContain(ctx.cwd) }) + it('scrubs random local spill paths under the snapshot cwd', () => { + const ev = JSON.stringify({ + type: 'tool/result', seq: 2, time: 5, + data: { + content: [{ + type: 'text', + text: `Full formatted result stored at: ${ctx.cwd}/.spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`, + }], + }, + }) + const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx) + expect(out).toContain('{{spillLocator:bash.txt}}') + expect(out).not.toContain('session-c22bc3f1d2af') + expect(out).not.toContain('8a7b6c5d4e3f') + }) + + it('scrubs macOS /private aliases for local spill paths', () => { + const ev = JSON.stringify({ + type: 'tool/result', seq: 2, time: 5, + data: { + content: [{ + type: 'text', + text: `Full formatted result stored at: /private${ctx.cwd}/.spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`, + }], + }, + }) + const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx) + expect(out).toContain('{{spillLocator:bash.txt}}') + expect(out).not.toContain('/private{{spillLocator') + }) + + it('scrubs fixed snapshot spill paths', () => { + const ev = JSON.stringify({ + type: 'tool/result', seq: 2, time: 5, + data: { + content: [{ + type: 'text', + text: 'Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.', + }], + }, + }) + const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx) + expect(out).toContain('{{spillLocator:bash.txt}}') + expect(out).not.toContain('/tmp/dsh-acp-snapshot-spill') + }) + it('scrubs the session id in the header', () => { const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx) expect(out).toContain('{{sessionId}}') diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index db8cf9e231..4624f72937 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -4,7 +4,7 @@ Runtime event-contract assertions intended for development diagnostics. This pur The plugin has no environment guard: it is active wherever it is registered. The default [`dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md) bundle mounts it unconditionally; a custom composition can omit it when the runtime cost is undesirable. It doubles as executable documentation of the event taxonomy — the assertions *are* the contract. -Session itself owns immutable log storage in every composition: it takes one lossless JSON snapshot of each accepted event, deep-freezes that record, and exposes the log through immutable array snapshots. The invariants plugin checks the cross-record and cross-seam rules that storage immutability cannot express. +Session itself owns immutable, surface-valid log storage in every composition: it takes one lossless JSON snapshot of each candidate, validates the complete surface transition, deep-freezes the accepted record, and exposes the log through immutable array snapshots. The invariants plugin checks the remaining cross-record and cross-seam rules that Session does not own. Session-log assertions run during Cordis `internal/dispatch`, while `Session.append()` is resolving the `session/event` callback snapshot but before it pushes the candidate into the log. A valid transition is staged by exact event identity and applied to the live trace only when that same committed event reaches the plugin's contained post-commit listener. A later internal dispatch check can therefore veto without advancing either the log or the invariant trace, while ordinary `session/event` observer failures remain observe-only. diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index d6e2b37946..ae7337b216 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -3,7 +3,8 @@ * turn and step nesting, scoped dispatch, status transitions, and request * reconstruction. The plugin has no environment guard and is active wherever * mounted, including the default `dsh-agent-spine-demo` bundle; custom compositions - * may omit it. Sessions still own event snapshots and freezing. + * may omit it. Sessions own immutable, surface-valid event storage; this plugin + * checks only relationships that event acceptance cannot express. * @module @deepseek-ai/dsh-invariants */ @@ -13,7 +14,7 @@ import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import { scopedSubjectResolverFor } from './scoped-events.generated.ts' export const name = 'invariants' @@ -48,15 +49,6 @@ interface SessionTrace { * `step/end` — a result must arrive in the same step as its call. */ pendingCalls: Set - /** Every seq seen so far — validates `sourceEventSeqs` references. */ - knownSeqs: Set - /** - * The seqs currently on the surface linked list, in linked-list order - * (head to tail). A replace reorders this relative to seq order (the new - * node takes the replaced range's position), so range validation is - * positional, not by seq comparison. - */ - surface: number[] } /** One accepted event's deferred mutation of a live session trace. */ @@ -68,12 +60,6 @@ interface SessionTraceTransition { | { kind: 'none' } | { kind: 'add' | 'delete'; callId: CallId } | { kind: 'clear' } - /** The event's mutation of the derived surface order. */ - surface: - | { kind: 'none' | 'append' } - | { kind: 'replace'; start: number; count: number } - /** The committed event sequence to add to the known-sequence set. */ - seq: number } /** Assert that a step-scoped event names the currently open turn and step. */ @@ -97,73 +83,6 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr let nextTurn = trace.nextTurn let nextStep = trace.nextStep let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' } - let surface: SessionTraceTransition['surface'] = { kind: 'none' } - - // --- Surface invariants --- - // Surface metadata (sourceEventSeqs, surfaceOp) is only valid on - // surface-eligible event types. The compiler enforces this at append() - // call sites; this runtime check catches casts and persisted data. - const SURFACE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result', 'context/message', 'steering/message']) - // Cast to surface-eligible event type so we can access surfaceOp and - // sourceEventSeqs (optional on SessionEvent, mandatory on SurfaceEvent). - // SurfaceEvent's mandatory surfaceOp is too strict here — we need to - // CHECK whether surface metadata is present, not assume it. - const se = event as SessionEvent - if (!SURFACE_TYPES.has(event.type)) { - if (se.sourceEventSeqs !== undefined) { - throw new InvariantError(`${event.type} cannot carry sourceEventSeqs (non-surface event)`) - } - if (se.surfaceOp !== undefined) { - throw new InvariantError(`${event.type} cannot carry surfaceOp (non-surface event)`) - } - } - if (se.sourceEventSeqs !== undefined) { - if (se.sourceEventSeqs.length === 0) { - throw new InvariantError('sourceEventSeqs must not be empty when present') - } - const unique = new Set(se.sourceEventSeqs) - if (unique.size !== se.sourceEventSeqs.length) { - throw new InvariantError('sourceEventSeqs must not contain duplicates') - } - for (const ref of se.sourceEventSeqs) { - if (ref >= event.seq) { - throw new InvariantError(`sourceEventSeqs must reference earlier events: ${ref} >= current seq ${event.seq}`) - } - if (!trace.knownSeqs.has(ref)) { - throw new InvariantError(`sourceEventSeqs references unknown seq ${ref}`) - } - } - } - // Fold this event into the tracked surface linked list, validating the - // replace contract as we go. `append` adds a tail node; `replace` shadows a - // positional range — every shadowed node must appear in sourceEventSeqs. - if (se.surfaceOp !== undefined) { - if (se.surfaceOp === 'append') { - surface = { kind: 'append' } - } else { - const { start, end } = se.surfaceOp - const startIdx = trace.surface.indexOf(start) - if (startIdx === -1) { - throw new InvariantError(`surface replace: start seq ${start} is not on the surface`) - } - const endIdx = trace.surface.indexOf(end) - if (endIdx === -1) { - throw new InvariantError(`surface replace: end seq ${end} is not on the surface`) - } - if (startIdx > endIdx) { - throw new InvariantError(`surface replace: start seq ${start} (pos ${startIdx}) is after end seq ${end} (pos ${endIdx}) on the surface`) - } - // Every node the replace shadows (surface positions [startIdx, endIdx] - // inclusive) must appear in sourceEventSeqs — the provenance contract. - const shadowed = trace.surface.slice(startIdx, endIdx + 1) - const recorded = new Set(se.sourceEventSeqs ?? []) - const missing = shadowed.filter(seq => !recorded.has(seq)) - if (missing.length > 0) { - throw new InvariantError(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`) - } - surface = { kind: 'replace', start: startIdx, count: shadowed.length } - } - } // Boundary/step-scoped events have explicit cases; every OTHER event type — // including plugin-added (merge-extensible) SessionEventMap keys — is caught @@ -263,8 +182,6 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr return { scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep }, pendingCalls, - surface, - seq: event.seq, } } @@ -287,20 +204,6 @@ function applyTransition(trace: SessionTrace, transition: SessionTraceTransition default: assertNever(transition.pendingCalls, 'session trace pending-call transition') } - switch (transition.surface.kind) { - case 'none': - break - case 'append': - trace.surface.push(transition.seq) - break - case 'replace': - trace.surface.splice(transition.surface.start, transition.surface.count, transition.seq) - break - /* v8 ignore next -- validateEvent produces this closed transition union */ - default: - assertNever(transition.surface, 'session trace surface transition') - } - trace.knownSeqs.add(transition.seq) } /** Validate and apply one event while rebuilding an already-committed log. */ @@ -345,8 +248,6 @@ export function apply(ctx: Context): void { nextTurn: 1, nextStep: 1, pendingCalls: new Set(), - knownSeqs: new Set(), - surface: [], }) /** Build (or rebuild) a session's trace by replaying its whole log. */ diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index c451440e8e..77c68a829c 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -464,7 +464,7 @@ describe('HMR safety', () => { }) }) -describe('surface invariants', () => { +describe('surface contract under the invariants composition', () => { it('accepts well-formed surface metadata', async () => { const { ctx } = await setup() const session = ctx.sessions.create() @@ -493,7 +493,7 @@ describe('surface invariants', () => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) expect(() => { session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [] }) - }).toThrow(InvariantError) + }).toThrow(/must not be empty/) }) it('rejects duplicate sourceEventSeqs', async () => { @@ -518,7 +518,8 @@ describe('surface invariants', () => { }) it('accepts sourceEventSeqs referencing a valid earlier event', async () => { - // Positive test: ref < current seq and ref is in knownSeqs → passes. + // Session seqs are contiguous, so every non-negative ref below the current + // seq necessarily names an existing earlier event. const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -538,23 +539,6 @@ describe('surface invariants', () => { }).toThrow(/must reference earlier/) }) - it('rejects sourceEventSeqs referencing unknown seq (gap in event log)', async () => { - // Create an impossible-through-public-API gap so seq 2 is earlier but unknown. - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - ;(session as unknown as { log: unknown[] }).log.push({ - type: 'assistant/chunk', - seq: 3, - time: Date.now(), - data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }, - }) - expect(() => { - session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [2] }) - }).toThrow(/unknown seq 2/) - }) - it('rejects a replace whose start is positioned after its end on the surface', async () => { const { ctx } = await setup() const session = ctx.sessions.create() @@ -565,7 +549,7 @@ describe('surface invariants', () => { // Reversed range: start seq 3 is at a later surface position than end seq 2. expect(() => { session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 2 }, sourceEventSeqs: [2, 3] }) - }).toThrow(/is after end seq 2 .* on the surface/) + }).toThrow(/is after end seq 2/) }) it('rejects a replace whose sourceEventSeqs omits a shadowed surface node', async () => { @@ -602,7 +586,7 @@ describe('surface invariants', () => { // seq 1 (step/start) is a real earlier event but never entered the surface. expect(() => { session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] }) - }).toThrow(/start seq 1 is not on the surface/) + }).toThrow(/start seq 1 not found in surface/) }) it('rejects a replace naming an end seq that is not on the surface', async () => { @@ -614,7 +598,7 @@ describe('surface invariants', () => { // start (2) is on the surface but end (99) never entered it. expect(() => { session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 99 }, sourceEventSeqs: [2] }) - }).toThrow(/end seq 99 is not on the surface/) + }).toThrow(/end seq 99 not found in surface/) }) it('rejects a replace whose range is reversed in surface position after a prior replace reordered it', async () => { @@ -631,7 +615,7 @@ describe('surface invariants', () => { // reversed positionally (3 is at pos 1, 4 is at pos 0). expect(() => { session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 4 }, sourceEventSeqs: [3, 4] }) // seq 5 - }).toThrow(/is after end seq 4 .* on the surface/) + }).toThrow(/is after end seq 4/) }) it('accepts a replace whose start seq exceeds its end seq when the surface position order is valid', async () => { @@ -675,25 +659,6 @@ describe('surface invariants', () => { expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(/must include every shadowed surface node; missing 3/) }) - it('rejects sourceEventSeqs on a non-surface event', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - // Session rejects this at its own acceptance boundary. Emit a hand-built - // record to cover the listener's defensive check for alternate producers. - const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, sourceEventSeqs: [0] } - expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) }) - .toThrow(/cannot carry sourceEventSeqs/) - }) - - it('rejects surfaceOp on a non-surface event', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, surfaceOp: 'append' } - expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) }) - .toThrow(/cannot carry surfaceOp/) - }) }) describe('request-reconstruction cross-check (llm/stream)', () => { 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..fe077d2624 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -5,8 +5,12 @@ 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 | +| `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool | `dsh-brand` is the canonical case: it owns ONLY the `Branded` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`. `dsh-timeout` follows the same shape for the timeout family: `dsh-bash` and `dsh-web-fetch-local` each fuse a caller's cancellation with a deadline and later classify "timed out" vs "cancelled" by depending on `dsh-timeout` alone. It deliberately owns only the timing/classification half — the *termination* (SIGKILL a process group, tear down a fetch socket) stays in each capability, because no shared layer can own every capability's kill (see [the timeout-library RFC](../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md)). + +`dsh-retention` is the same split for bounded tool output: a tool (`glob`/`grep`/`bash`/`web_fetch`/`web_search`) feeds items or text into a retainer and gets back what it kept and exactly what it omitted — while grouping, exit codes, provider errors, and recovery prose stay tool-owned. It deliberately owns only the retention mechanic; `truncated` is a budget fact, never an "incomplete inspection" state (see [the retention-library RFC](../../docs/rfc/implemented/architecture/2026-07-06-tool-result-retention-library.md)). 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/packages/util/retention/README.md b/packages/util/retention/README.md new file mode 100644 index 0000000000..6ca095be41 --- /dev/null +++ b/packages/util/retention/README.md @@ -0,0 +1,91 @@ +# dsh-retention + +A dependency-light **retention** library: bounded model-facing output for tools that must cap how much context they return. A caller feeds items or text chunks into a bounded object, then gets the retained content plus exact omission metadata. + +The library owns **only** the mechanical question *"what did we keep, and what did we omit?"*. Tool-specific code keeps its business semantics: file grouping, line numbering, exit codes, provider error states, per-line preview truncation, spill files, and the model-facing prose. This is the boundary the [RFC](../../../docs/rfc/implemented/architecture/2026-07-06-tool-result-retention-library.md) draws. + +It is a **library, not a service or plugin**: no `ctx`, registers nothing, emits no events. The only state is per-retainer (one accumulation), never cross-call. Tool packages import it directly. + +## Surface + +```ts +import { + ItemRetainer, TextRetainer, + describeOmitted, formatRetentionNotice, +} from '@deepseek-ai/dsh-retention' +import type { + Omitted, PushDecision, RetainedItems, RetainedText, + ItemRetentionStrategy, TextRetentionStrategy, RetentionNotice, +} from '@deepseek-ai/dsh-retention' +``` + +| Export | Role | +|---|---| +| `ItemRetainer` | Bounds ordered logical units (paths, grep matches, sources). `head` only in v1. `push()` → `PushDecision`; `finish()` → `RetainedItems`. | +| `TextRetainer` | Bounds a byte-oriented text stream. `head` / `tail` / `headTail`, UTF-8 boundaries preserved at `finish()`. `push()` → `PushDecision`; `finish()` → `RetainedText`. | +| `describeOmitted(omitted, unit)` | Standardized omission clause (`exact` prints a count; `unknown` does not). | +| `formatRetentionNotice(notice, recovery)` | Joins the standardized omission clause with the tool's own recovery guidance. | +| `Omitted` | `none` / `exact` / `unknown` — how much was omitted. | +| `PushDecision` | `{ kept, truncated }` — the per-push retention result. | + +## Resource Modes + +The two retainers are separate names, not one generic collector, because they differ in **resource model**. + +- **`ItemRetainer` bounds ordered logical units.** A search tool can collect a full result set for spill-file recovery while retaining only the first `maxItems` for the model-facing preview. The omission count is exact because the caller keeps feeding every observed item. +- **`TextRetainer` bounds byte-oriented text.** `head`, `tail`, and `headTail` preserve UTF-8 boundaries at `finish()`; `headTail` is the shape `dsh-spill-policy` uses to build a bounded preview around a spill-file notice. + +## `truncated` is a budget fact, never "incomplete" + +`truncated` means *the retainer omitted otherwise-available content because of a budget*. It does **not** mean the upstream was incomplete. Permission failures, skipped binary files, provider partial failures, unreadable candidates, and invalid UTF-8 stay in tool-domain fields — never folded into `truncated`. Conflating the two is the bug this library's naming most invites; keep them separate. + +## Bytes, not characters + +Text caps and `omittedBytes` count **bytes**, for process/body safety (a child's pipe and an HTTP body are byte streams). A chunk that straddles a codepoint is handled: `finish()` trims a partial codepoint at each cut so the returned text never introduces a replacement char at the boundary, and the two sides are decoded separately so a codepoint is never reconstructed across the omitted middle. Character- or line-level preview budgets are a separate, tool-owned concern. + +## Tool mappings + +Every current retention consumer maps to the library below. A broad migration is out of scope for the library's first landing — these are the intended shapes. + +| Tool | Retainer & strategy | Notes | +|---|---|---| +| `glob` | `ItemRetainer`, `head` | Collect the full sorted path list for a spill file while retaining the first page inline. Path mapping, skipped candidates, and `incomplete` stay outside. | +| `grep` | `ItemRetainer`, `head` | Collect matches for a spill file while retaining the first page inline. Per-match preview truncation, grouping, sorting, and `incomplete` stay outside. | +| `bash` | `TextRetainer`, `tail` or `headTail` | Executor still owns spill files, exit status, signal, timeout, and background tasks. | +| `web_fetch` | `TextRetainer`, `head` or `headTail` | Provider/resource caps stay provider facts; the retainer supplies only retained text and omission metadata. | +| `web_search` | `ItemRetainer`, `head` | Standardizes the "sources capped" notice when providers return more sources than the model-facing result should include. | + +`read` is **intentionally out of scope for v1.** Its `read-render` helper owns a file-specific pagination contract — `offset`/`limit`, line numbers, `totalLines`, offset-out-of-range errors, per-line preview truncation, a byte cap over the selected window — which is a line-window renderer, not generic retention. A single `Omitted` count cannot represent both sides of a line window. + +## Usage shape + +```ts ignore-check +// glob: keep the first page inline while still collecting the full list for spill. +const retainer = new ItemRetainer({ kind: 'head', maxItems: globMaxResults }) +const allEntries: FsGlobEntry[] = [] +for await (const entry of candidates) { + allEntries.push(entry) + retainer.push(entry) +} +const { items, truncated, omitted } = retainer.finish() + +// bash: keep a head + tail, read to process exit. +const out = new TextRetainer({ kind: 'headTail', headBytes: headCap, tailBytes: tailCap }) +child.stdout.on('data', (chunk: Buffer) => { out.push(chunk) }) +const { text, omittedBytes } = out.finish() + +// A footer: the library standardizes the omission clause; the tool owns recovery words. +const footer = formatRetentionNotice( + { scope: 'grep', strategy: 'head', unit: 'items', limit: grepMaxMatches, kept: items.length, omitted }, + ({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`, +) +``` + +## Model Experience + +Indirectly, through tool consumers that render retained content and omission metadata. + +## Known Limitations and Deferred Work + +- **Item retention supports `head` only** — tail, head/tail, pagination, grouping, and provider-completeness semantics remain tool-owned. +- **Text retention is byte-oriented** — line and character windows such as `read` pagination require a separate renderer, and a cut may discard partial UTF-8 boundary bytes to keep returned text valid. diff --git a/packages/util/retention/package.json b/packages/util/retention/package.json new file mode 100644 index 0000000000..db8bab3342 --- /dev/null +++ b/packages/util/retention/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-retention", + "description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/util/retention/src/index.ts b/packages/util/retention/src/index.ts new file mode 100644 index 0000000000..07547a7d93 --- /dev/null +++ b/packages/util/retention/src/index.ts @@ -0,0 +1,444 @@ +/** + * A dependency-light **retention** library: bounded model-facing output for + * tools that must cap how much context they return. A caller feeds items or + * text chunks into a bounded object, then gets the retained content plus exact + * omission metadata ({@link RetainedItems} / {@link RetainedText}). + * + * The library owns ONLY the mechanical question "what did we keep, what did we + * omit?". Tool-specific code still owns + * business semantics: file grouping, line numbering, exit codes, provider error + * states, per-line preview truncation, spill files, and the model-facing prose. + * In particular {@link RetainedText.truncated}/{@link RetainedItems.truncated} + * means "the retainer omitted otherwise-available content because of a budget" — + * NOT "the upstream was incomplete". Permission failures, skipped binaries, + * provider partial failures, and unreadable candidates stay in tool-domain + * fields, never folded into `truncated`. + * + * This is deliberately a library, not a cordis service or plugin: it takes no + * `ctx`, registers nothing, and emits no events. The two retainers are the only + * stateful pieces and their state is per-instance (one accumulation), never + * cross-call. Tool packages import it directly when they need bounded output. + * + * The two retainers differ in resource model, which is why they are two names + * rather than one generic collector: + * - {@link ItemRetainer} bounds ordered logical units (paths, grep matches, + * search sources). `head` retention only in v1. + * - {@link TextRetainer} bounds byte-oriented text streams (bash stdout/stderr, + * web bodies). `head` / `tail` / `headTail`, preserving UTF-8 boundaries at + * {@link TextRetainer.finish}. + * + * @module @deepseek-ai/dsh-retention + */ + +/** + * How much content the retainer omitted. + * + * `exact` is the normal retainer shape: every unit/byte was observed, so the + * omitted count is precise. `unknown` is reserved for a caller that omits + * without a count; the retainers themselves never return it. + */ +export type Omitted = + | { kind: 'none' } + | { kind: 'exact'; count: number } + | { kind: 'unknown' } + +/** + * The caller receives this after each `push()`. + */ +export interface PushDecision { + /** Was this whole unit / all of this chunk's bytes retained (nothing dropped)? */ + kept: boolean + /** Cumulative: has the retainer omitted anything due to the budget yet? */ + truncated: boolean +} + +/** + * Final result for ordered logical units. + * + * `seen` means units OBSERVED by the retainer, not necessarily the total in the + * upstream source. `kept` is `items.length`, surfaced explicitly so a notice + * formatter need not re-count. + */ +export interface RetainedItems { + items: T[] + truncated: boolean + seen: number + kept: number + omitted: Omitted +} + +/** + * Final result for text streams. + * + * The returned `text` is safe to hand to a formatter: the retainer adds no + * tool-specific headers, exit markers, XML tags, or recovery instructions, and + * `omittedBytes` counts BYTES (not characters or lines) — text retention is + * byte-oriented for process/body safety. UTF-8 boundaries at each cut are + * preserved, so `text` never carries a replacement char introduced by the cut + * itself. + */ +export interface RetainedText { + text: string + truncated: boolean + omittedBytes: Omitted +} + +/** Item retention strategy. Only `head` in v1; windows/grouped budgets wait for a second consumer. */ +export type ItemRetentionStrategy = { + /** Keep the first `maxItems` units. Use for `glob`, `grep`, and web sources. */ + kind: 'head' + maxItems: number +} + +/** Text retention strategy: keep a prefix, a suffix, or both, counted in bytes. */ +export type TextRetentionStrategy = + | { + /** Keep the first `maxBytes` bytes. */ + kind: 'head' + maxBytes: number + } + | { + /** Keep the final `maxBytes` bytes. Requires reading to the end. */ + kind: 'tail' + maxBytes: number + } + | { + /** Keep a stable prefix and suffix, omitting the middle. Requires reading to the end. */ + kind: 'headTail' + headBytes: number + tailBytes: number + } + +/** + * A neutral, tool-agnostic description of one retention outcome — the input to + * {@link formatRetentionNotice}. It carries the mechanical facts (strategy, + * unit, limit, kept count, {@link Omitted}); the tool supplies the recovery + * words, because only the tool knows the recovery action ("narrow the pattern", + * "fetch a more specific URL", "read the spill file"). + */ +export interface RetentionNotice { + /** Tool/scope label, e.g. `grep`, `web_fetch`, `bash stdout`. */ + scope: string + strategy: 'head' | 'tail' | 'headTail' + unit: 'items' | 'bytes' | 'chars' | 'lines' + limit: number | { head: number; tail: number } + kept: number + omitted: Omitted +} + +/** Assert a budget field is a non-negative integer (the retainer request contract). */ +function assertBudget(value: number, name: string): void { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative integer`) + } +} + +/** + * Bounds an ordered stream of logical units, keeping the first `maxItems` + * ({@link ItemRetentionStrategy} `head`). `push()` reports, per unit, whether it + * was kept and whether the retained result is now truncated. + * + * Grouping, sorting, path mapping, per-unit preview truncation, and any + * `incomplete` state stay OUTSIDE the retainer: it counts and keeps, nothing + * more. The caller pushes already-shaped units and, after {@link finish}, + * groups/sorts the retained subset itself. + */ +export class ItemRetainer { + private readonly maxItems: number + private readonly items: T[] = [] + private seen = 0 + private omittedCount = 0 + + /** @param strategy Head strategy: `maxItems` (non-negative integer). */ + constructor(strategy: ItemRetentionStrategy) { + assertBudget(strategy.maxItems, 'maxItems') + this.maxItems = strategy.maxItems + } + + /** + * Offer one unit. Kept when the retainer is below `maxItems`; otherwise dropped + * and counted as omitted. Callers keep pushing all observed units, so the final + * {@link Omitted} count is exact. + * + * @param item The already-shaped logical unit (path, flat match, source). + * @returns The per-push {@link PushDecision}. + */ + push(item: T): PushDecision { + this.seen++ + if (this.items.length < this.maxItems) { + // Reached only below the cap, before any omission (items only grow, the + // cap is fixed), so nothing has been dropped yet: truncated is always false. + this.items.push(item) + return { kept: true, truncated: false } + } + this.omittedCount++ + return { + kept: false, + truncated: true, + } + } + + /** + * Finalize and report what was kept and omitted. + * + * @returns The {@link RetainedItems} snapshot (safe to group/sort downstream). + */ + finish(): RetainedItems { + const truncated = this.omittedCount > 0 + return { + items: this.items, + truncated, + seen: this.seen, + kept: this.items.length, + omitted: truncated + ? { kind: 'exact', count: this.omittedCount } + : { kind: 'none' }, + } + } +} + +const encoder = new TextEncoder() +const decoder = new TextDecoder() // utf-8, non-fatal: internal malformed bytes → U+FFFD + +/** + * Drop a trailing incomplete UTF-8 sequence so a prefix cut never emits a + * replacement char at the boundary. Walks back over continuation bytes + * (`10xxxxxx`) to the lead byte; if fewer bytes follow it than the lead byte's + * length declares, the sequence is incomplete and is trimmed. A complete tail, + * or a run too long/short to be a valid lead, is returned untouched (any + * genuinely malformed interior is left for the decoder to replace). + */ +function trimTrailingPartialUtf8(bytes: Uint8Array): Uint8Array { + let i = bytes.length - 1 + // Continuation bytes are 0b10xxxxxx; scan back at most 3 (max sequence is 4). + // Indices are bounds-checked by the loop guard, so the reads are in range (a + // cast, not `!`, per the repo's no-non-null-assertion rule). + while (i >= 0 && ((bytes[i] as number) & 0xc0) === 0x80 && bytes.length - i <= 3) i-- + if (i < 0) return bytes + const lead = bytes[i] as number + const expected = lead < 0x80 ? 1 : lead < 0xe0 ? 2 : lead < 0xf0 ? 3 : lead < 0xf8 ? 4 : 0 + // expected 0 → not a lead byte (stray continuation / invalid): leave it. + if (expected === 0) return bytes + return bytes.length - i < expected ? bytes.subarray(0, i) : bytes +} + +/** + * Drop leading continuation bytes (`10xxxxxx`) so a suffix cut starts on a + * lead/ASCII byte instead of mid-codepoint. + */ +function trimLeadingContinuationUtf8(bytes: Uint8Array): Uint8Array { + let i = 0 + // i < length guards the read; cast rather than `!` (no-non-null-assertion). + while (i < bytes.length && ((bytes[i] as number) & 0xc0) === 0x80) i++ + return bytes.subarray(i) +} + +/** + * Bounds a byte-oriented text stream, keeping a prefix, a suffix, or both + * ({@link TextRetentionStrategy}). All three strategies share one prefix/suffix + * accumulator: `head` is prefix-only, `tail` is suffix-only, `headTail` is both. + * + * Bytes, not characters: caps and `omittedBytes` are byte counts for process/ + * body safety. Chunks that straddle a codepoint are handled — {@link finish} + * trims a partial codepoint at each cut so the returned text never introduces a + * replacement char at the boundary. The retainer holds at most + * `prefixCap + tailBytes + one chunk` in memory (old suffix chunks are dropped + * as they slide out), so a large stream does not accumulate unbounded. + */ +export class TextRetainer { + private readonly prefixCap: number + private readonly suffixCap: number + private readonly prefixChunks: Uint8Array[] = [] + private prefixHeld = 0 + private readonly suffixChunks: Uint8Array[] = [] + private suffixHeld = 0 + private total = 0 + + /** @param strategy One of the {@link TextRetentionStrategy} shapes; byte budgets must be non-negative integers. */ + constructor(strategy: TextRetentionStrategy) { + switch (strategy.kind) { + case 'head': + assertBudget(strategy.maxBytes, 'maxBytes') + this.prefixCap = strategy.maxBytes + this.suffixCap = 0 + break + case 'tail': + assertBudget(strategy.maxBytes, 'maxBytes') + this.prefixCap = 0 + this.suffixCap = strategy.maxBytes + break + case 'headTail': + assertBudget(strategy.headBytes, 'headBytes') + assertBudget(strategy.tailBytes, 'tailBytes') + this.prefixCap = strategy.headBytes + this.suffixCap = strategy.tailBytes + break + } + } + + /** + * Offer one chunk (a `Uint8Array`, or a `string` encoded as UTF-8). Prefix + * bytes fill up to the prefix cap then stop; suffix bytes roll so only the + * last `suffixCap` bytes are retained. `kept` is `true` only when no byte of + * this chunk was dropped. + * + * @param chunk The next bytes of the stream (`Uint8Array` or UTF-8 `string`). + * @returns The per-push {@link PushDecision}. + */ + push(chunk: Uint8Array | string): PushDecision { + const bytes = typeof chunk === 'string' ? encoder.encode(chunk) : chunk + const before = this.total + this.total += bytes.length + + // Prefix: take only up to the cap; the rest of this chunk is "not prefixed". + const room = this.prefixCap - this.prefixHeld + const take = Math.max(0, Math.min(room, bytes.length)) + if (take > 0) { + this.prefixChunks.push(bytes.subarray(0, take)) + this.prefixHeld += take + } + + // Suffix: append the whole chunk, then drop whole leading chunks that have + // fully slid out of the last `suffixCap` bytes (bounded memory). + if (this.suffixCap > 0) { + this.suffixChunks.push(bytes) + this.suffixHeld += bytes.length + let head = this.suffixChunks[0] + while (head !== undefined && this.suffixHeld - head.length >= this.suffixCap) { + this.suffixChunks.shift() + this.suffixHeld -= head.length + head = this.suffixChunks[0] + } + // The head chunk can still hold leading bytes beyond the last `suffixCap` + // — a single chunk LARGER than the window is retained whole by the loop + // above (dropping the only chunk would leave < cap). Trim those leading + // bytes so the accumulator (and finish()'s concat) stays bounded by + // `suffixCap` instead of allocating/copying the full chunk again; + // finish() only ever reads the last `suffixLen ≤ suffixCap` bytes, so this + // drops nothing it would return. (head.length > excess by the loop + // invariant `suffixHeld - head.length < suffixCap`, so the slice is non-empty.) + if (head !== undefined && this.suffixHeld > this.suffixCap) { + const excess = this.suffixHeld - this.suffixCap + this.suffixChunks[0] = head.subarray(excess) + this.suffixHeld -= excess + } + } + + // Dropped = bytes that no side can keep. Compute cumulative omission the + // SAME way finish() does (via omittedAt), so push and finish never disagree; + // per-push we only need whether THIS chunk pushed the total past what the + // two caps hold. + const droppedThisChunk = this.omittedAt(this.total) > this.omittedAt(before) + return { + kept: !droppedThisChunk, + truncated: this.omittedAt(this.total) > 0, + } + } + + /** Bytes omitted once `total` bytes have been seen: `total − keptPrefix − keptSuffix`. */ + private omittedAt(total: number): number { + const prefixLen = Math.min(total, this.prefixCap) + const suffixLen = Math.min(total - prefixLen, this.suffixCap) + return total - prefixLen - suffixLen + } + + /** + * Finalize: decode the retained prefix and suffix (each trimmed to a UTF-8 + * boundary at its cut) and report the exact omitted byte count. + * + * @returns The {@link RetainedText} snapshot (safe to hand to a formatter). + */ + finish(): RetainedText { + const prefixLen = Math.min(this.total, this.prefixCap) + const suffixLen = Math.min(this.total - prefixLen, this.suffixCap) + + const prefix = concat(this.prefixChunks) // exactly prefixLen bytes (prefixHeld === prefixLen) + const suffix = concat(this.suffixChunks).subarray(this.suffixHeld - suffixLen) + + // With nothing omitted by budget, prefix and suffix are ADJACENT slices of + // one stream (prefixLen + suffixLen === total), so the head|tail split is + // artificial: a codepoint may span it. Decode the contiguous whole as one + // buffer — trimming or decoding the halves separately here would corrupt a + // boundary-spanning codepoint though no content was dropped. Only a real + // omitted gap makes each side a true cut: trim each to a UTF-8 boundary and + // decode separately so a codepoint is never reconstructed across the gap. + const budgetOmitted = this.omittedAt(this.total) + const [keptPrefix, keptSuffix] = budgetOmitted > 0 + ? [trimTrailingPartialUtf8(prefix), trimLeadingContinuationUtf8(suffix)] + : [prefix, suffix] + const text = budgetOmitted > 0 + ? decoder.decode(keptPrefix) + decoder.decode(keptSuffix) + : decoder.decode(concat([prefix, suffix])) + + // Report omission against the bytes ACTUALLY returned, not the pre-trim + // budget: a boundary trim drops partial-codepoint bytes too, so an exact + // count derived from the budget alone would overstate the retained text (and + // any "Omitted N bytes" notice built from it would be a lie). + const omitted = this.total - keptPrefix.length - keptSuffix.length + const truncated = omitted > 0 + + return { + text, + truncated, + omittedBytes: truncated + ? { kind: 'exact', count: omitted } + : { kind: 'none' }, + } + } +} + +/** Concatenate chunks into one contiguous buffer (their exact total length). */ +function concat(chunks: readonly Uint8Array[]): Uint8Array { + let length = 0 + for (const chunk of chunks) length += chunk.length + const out = new Uint8Array(length) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.length + } + return out +} + +/** + * Standardized, false-precision-safe wording for one {@link Omitted} value — + * the "may standardize omission wording" half the library owns. `exact` prints + * the count (`Omitted 3 items`); `unknown` prints NO count because the caller + * did not provide one. `none` is the empty string. + * + * @param omitted The omission metadata from a retainer result. + * @param unit The noun for the omitted quantity (`items`, `bytes`, `chars`, `lines`). + * @returns A neutral clause (no trailing space), or `''` when nothing was omitted. + */ +export function describeOmitted(omitted: Omitted, unit: RetentionNotice['unit']): string { + switch (omitted.kind) { + case 'none': + return '' + case 'exact': + return `Omitted ${omitted.count} ${unit}.` + case 'unknown': + return `More ${unit} were omitted.` + } +} + +/** + * Turn a {@link RetentionNotice} into a one-line footer: the library-owned + * standardized omission clause ({@link describeOmitted}) followed by the tool's + * own recovery guidance. The library never owns recovery words — only the tool + * knows the action ("narrow the pattern", "fetch a more specific URL", "read the + * spill file") — so `recovery` supplies them and receives the full notice to + * phrase from (`kept`, `limit`, `omitted`, …). Either half may be empty; the two + * are joined with a single space. + * + * @param notice The neutral retention outcome. + * @param recovery Tool-supplied guidance builder; receives the notice, returns a sentence (or `''`). + * @returns The combined footer line. + */ +export function formatRetentionNotice( + notice: RetentionNotice, + recovery: (notice: RetentionNotice) => string, +): string { + return [describeOmitted(notice.omitted, notice.unit), recovery(notice)] + .filter(part => part.length > 0) + .join(' ') +} diff --git a/packages/util/retention/tests/retention.spec.ts b/packages/util/retention/tests/retention.spec.ts new file mode 100644 index 0000000000..8fac7d8575 --- /dev/null +++ b/packages/util/retention/tests/retention.spec.ts @@ -0,0 +1,376 @@ +import { describe, expect, it } from 'vitest' +import { + describeOmitted, + formatRetentionNotice, + ItemRetainer, + type Omitted, + type RetentionNotice, + TextRetainer, +} from '@deepseek-ai/dsh-retention' + +/** Decode a RetainedText via a round-trip helper for readable UTF-8 assertions. */ +const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s) + +describe('ItemRetainer — head retention', () => { + it('keeps the first maxItems while callers keep draining for an exact omitted count', () => { + const r = new ItemRetainer({ kind: 'head', maxItems: 2 }) + expect(r.push('a')).toEqual({ kept: true, truncated: false }) + expect(r.push('b')).toEqual({ kept: true, truncated: false }) + expect(r.push('c')).toEqual({ kept: false, truncated: true }) + + const result = r.finish() + expect(result.items).toEqual(['a', 'b']) + expect(result.kept).toBe(2) + expect(result.seen).toBe(3) + expect(result.truncated).toBe(true) + expect(result.omitted).toEqual({ kind: 'exact', count: 1 }) + }) + + it('reports none when everything fits', () => { + const r = new ItemRetainer({ kind: 'head', maxItems: 3 }) + r.push(1) + r.push(2) + const result = r.finish() + expect(result.items).toEqual([1, 2]) + expect(result.truncated).toBe(false) + expect(result.omitted).toEqual({ kind: 'none' }) + }) + it('keeps draining past the cap and reports an exact omitted count', () => { + const r = new ItemRetainer({ kind: 'head', maxItems: 1 }) + expect(r.push('a')).toEqual({ kept: true, truncated: false }) + expect(r.push('b')).toEqual({ kept: false, truncated: true }) + expect(r.push('c')).toEqual({ kept: false, truncated: true }) + + const result = r.finish() + expect(result.items).toEqual(['a']) + expect(result.seen).toBe(3) + expect(result.omitted).toEqual({ kind: 'exact', count: 2 }) + }) +}) + +describe('ItemRetainer — zero budget', () => { + it('keeps nothing and counts every pushed item as omitted', () => { + const r = new ItemRetainer({ kind: 'head', maxItems: 0 }) + expect(r.push('a')).toEqual({ kept: false, truncated: true }) + const result = r.finish() + expect(result.items).toEqual([]) + expect(result.kept).toBe(0) + expect(result.omitted).toEqual({ kind: 'exact', count: 1 }) + }) + + it('rejects a non-integer / negative maxItems', () => { + expect(() => new ItemRetainer({ kind: 'head', maxItems: -1 })) + .toThrow(/maxItems must be a non-negative integer/) + expect(() => new ItemRetainer({ kind: 'head', maxItems: 1.5 })) + .toThrow(/maxItems must be a non-negative integer/) + }) +}) + +describe('TextRetainer — head (exact omission, reads to end)', () => { + it('keeps the prefix and counts omitted bytes exactly', () => { + const r = new TextRetainer({ kind: 'head', maxBytes: 5 }) + expect(r.push('abc')).toEqual({ kept: true, truncated: false }) + // 'de' fills the cap exactly (5 bytes) — still fully kept. + expect(r.push('de')).toEqual({ kept: true, truncated: false }) + expect(r.push('fgh')).toEqual({ kept: false, truncated: true }) + + const result = r.finish() + expect(result.text).toBe('abcde') + expect(result.truncated).toBe(true) + expect(result.omittedBytes).toEqual({ kind: 'exact', count: 3 }) + }) + + it('flags a partially-dropped chunk as not fully kept', () => { + const r = new TextRetainer({ kind: 'head', maxBytes: 4 }) + r.push('ab') + // 'cde' straddles the cap: 'c','d' fit, 'e' drops → kept:false. + expect(r.push('cde')).toEqual({ kept: false, truncated: true }) + expect(r.finish().text).toBe('abcd') + }) + + it('keeps draining past the cap', () => { + const r = new TextRetainer({ kind: 'head', maxBytes: 3 }) + r.push('abc') + expect(r.push('defg')).toEqual({ kept: false, truncated: true }) + const result = r.finish() + expect(result.text).toBe('abc') + expect(result.omittedBytes).toEqual({ kind: 'exact', count: 4 }) + }) +}) + +describe('TextRetainer — tail (exact omission, reads to end)', () => { + it('keeps the final maxBytes and reports exact omission', () => { + const r = new TextRetainer({ kind: 'tail', maxBytes: 4 }) + expect(r.push('hello')).toEqual({ kept: false, truncated: true }) + r.push('world') + const result = r.finish() + expect(result.text).toBe('orld') // last 4 bytes of 'helloworld' + expect(result.truncated).toBe(true) + expect(result.omittedBytes).toEqual({ kind: 'exact', count: 6 }) + }) + + it('keeps everything when the stream is under the cap', () => { + const r = new TextRetainer({ kind: 'tail', maxBytes: 100 }) + r.push('short') + const result = r.finish() + expect(result.text).toBe('short') + expect(result.truncated).toBe(false) + expect(result.omittedBytes).toEqual({ kind: 'none' }) + }) + + it('drops old chunks as they slide out of the tail window', () => { + const r = new TextRetainer({ kind: 'tail', maxBytes: 3 }) + for (const c of ['11', '22', '33', '44']) r.push(c) + // Only the final 3 bytes survive; earlier whole chunks are dropped. + expect(r.finish().text).toBe('344') + }) +}) + +describe('TextRetainer — headTail (prefix + suffix, omit the middle)', () => { + it('keeps a stable head and tail, omitting the middle exactly', () => { + const r = new TextRetainer({ kind: 'headTail', headBytes: 3, tailBytes: 3 }) + r.push('abcdefghij') // 10 bytes: head 'abc', tail 'hij', middle 'defg' omitted + const result = r.finish() + expect(result.text).toBe('abchij') + expect(result.truncated).toBe(true) + expect(result.omittedBytes).toEqual({ kind: 'exact', count: 4 }) + }) + + it('does not double-count when head+tail cover the whole stream', () => { + const r = new TextRetainer({ kind: 'headTail', headBytes: 3, tailBytes: 3 }) + r.push('abcdef') // exactly head(3) + tail(3), nothing omitted + const result = r.finish() + expect(result.text).toBe('abcdef') + expect(result.truncated).toBe(false) + expect(result.omittedBytes).toEqual({ kind: 'none' }) + }) + + it('does not drop a codepoint that spans the head|tail split when nothing is omitted', () => { + // Regression: with head+tail covering the whole stream, the split is + // artificial — a multibyte codepoint may straddle it. 'éab' is C3 A9 61 62 + // (4 bytes); headBytes 1 + tailBytes 3 covers all 4 with omitted === 0, but + // the split falls INSIDE 'é'. The bytes are contiguous, so the full 'éab' + // must survive — not be trimmed to 'ab'. + const r = new TextRetainer({ kind: 'headTail', headBytes: 1, tailBytes: 3 }) + r.push('éab') + const result = r.finish() + expect(result.text).toBe('éab') + expect(result.truncated).toBe(false) + expect(result.omittedBytes).toEqual({ kind: 'none' }) + }) + + it('still trims boundary partials once a real middle is omitted', () => { + // With a genuine gap the two sides ARE true cuts: '€' (3 bytes) split across + // the omitted middle must not resurface as a replacement char on either side. + const r = new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 2 }) + r.push('a€€b') // 8 bytes; head 'a'+partial, tail partial+'b', middle omitted + const result = r.finish() + expect(result.truncated).toBe(true) + expect(result.text).not.toContain('�') + expect(result.text.startsWith('a')).toBe(true) + expect(result.text.endsWith('b')).toBe(true) + }) +}) + +describe('TextRetainer — zero budgets', () => { + it('head maxBytes 0 keeps nothing and counts every byte exactly', () => { + const r = new TextRetainer({ kind: 'head', maxBytes: 0 }) + expect(r.push('x')).toEqual({ kept: false, truncated: true }) + const result = r.finish() + expect(result.text).toBe('') + expect(result.omittedBytes).toEqual({ kind: 'exact', count: 1 }) + }) + + it('an empty stream omits nothing', () => { + const r = new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 2 }) + const result = r.finish() + expect(result.text).toBe('') + expect(result.truncated).toBe(false) + expect(result.omittedBytes).toEqual({ kind: 'none' }) + }) + + it('rejects non-integer / negative byte budgets', () => { + expect(() => new TextRetainer({ kind: 'head', maxBytes: -1 })) + .toThrow(/maxBytes must be a non-negative integer/) + expect(() => new TextRetainer({ kind: 'tail', maxBytes: 2.5 })) + .toThrow(/maxBytes must be a non-negative integer/) + expect(() => new TextRetainer({ kind: 'headTail', headBytes: -1, tailBytes: 2 })) + .toThrow(/headBytes must be a non-negative integer/) + expect(() => new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 1.1 })) + .toThrow(/tailBytes must be a non-negative integer/) + }) +}) + +describe('TextRetainer — UTF-8 boundary handling', () => { + it('trims a partial codepoint at the head cut instead of emitting U+FFFD', () => { + // '€' is 3 bytes (E2 82 AC). A 2-byte head cap keeps 'a' (61) + the first + // byte of '€' (E2); that partial lead byte must be trimmed, not decoded to + // a replacement char. + const r = new TextRetainer({ kind: 'head', maxBytes: 2 }) + r.push('a€b') // bytes: 61 E2 82 AC 62 + const result = r.finish() + expect(result.text).toBe('a') // partial '€' dropped, no U+FFFD + expect(result.text).not.toContain('�') + // Omission counts bytes ACTUALLY absent from the returned text, including + // the partial 'E2' the boundary trim dropped: 5 total − 1 retained = 4 + // (not the pre-trim budget of 3, which would overstate what was kept). + expect(result.omittedBytes).toEqual({ kind: 'exact', count: 4 }) + }) + + it('trims a leading partial codepoint at the tail cut', () => { + // Tail cap 2 over 'a€b' (5 bytes) keeps AC 62 — AC is a continuation byte + // (the middle of '€'); the leading continuation byte is dropped so the tail + // begins on a boundary. + const r = new TextRetainer({ kind: 'tail', maxBytes: 2 }) + r.push('a€b') + const result = r.finish() + expect(result.text).toBe('b') // partial '€' at the front dropped + expect(result.text).not.toContain('�') + // Honest count: 5 total − 1 retained ('b') = 4, including the trimmed AC. + expect(result.omittedBytes).toEqual({ kind: 'exact', count: 4 }) + }) + + it('omitted count matches the bytes actually absent, across a headTail boundary trim', () => { + // Regression: the exact count must equal total − retained (post-trim), never + // the pre-trim budget. 'a€€b' is 8 bytes (61 E2828C… ×2 61? no: 61 E2 82 AC + // E2 82 AC 62). headBytes 2 keeps 'a'+partial-E2 → trims to 'a' (1 byte); + // tailBytes 2 keeps partial-AC+'b' → trims to 'b' (1 byte). Retained text is + // 2 bytes, so omitted must be 8 − 2 = 6 — not the budget's 8 − 2 − 2 = 4. + const r = new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 2 }) + r.push('a€€b') + const result = r.finish() + const retainedBytes = new TextEncoder().encode(result.text).length + expect(result.omittedBytes).toEqual({ kind: 'exact', count: 8 - retainedBytes }) + }) + + it('preserves a whole multibyte codepoint that fits exactly', () => { + const r = new TextRetainer({ kind: 'head', maxBytes: 3 }) + r.push('€x') // '€' is exactly 3 bytes + expect(r.finish().text).toBe('€') + }) + + it('does not reconstruct a codepoint across the omitted middle', () => { + // headBytes ends mid-'€' and tailBytes starts mid-another '€'; neither cut + // may glue a valid codepoint across the gap. + const r = new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 2 }) + r.push('€€€') // 9 bytes + const result = r.finish() + expect(result.text).not.toContain('�') + expect(result.truncated).toBe(true) + }) + + it('accepts a raw Uint8Array chunk', () => { + const r = new TextRetainer({ kind: 'head', maxBytes: 2 }) + r.push(utf8('xy')) + r.push(utf8('z')) + expect(r.finish().text).toBe('xy') + }) + + it('trims a partial 2-byte codepoint at the head cut', () => { + // 'é' is 2 bytes (C3 A9). A 2-byte head cap over 'aé' keeps 'a' (61) + the + // lead byte of 'é' (C3) — an incomplete 2-byte sequence to trim. + const r = new TextRetainer({ kind: 'head', maxBytes: 2 }) + r.push('aé') // bytes: 61 C3 A9 + const result = r.finish() + expect(result.text).toBe('a') + expect(result.text).not.toContain('�') + }) + + it('trims a partial 4-byte codepoint (emoji) at the head cut', () => { + // '😀' is 4 bytes (F0 9F 98 80). A 3-byte head cap keeps 'a' + the first two + // bytes of the emoji — an incomplete 4-byte sequence that must be trimmed. + const r = new TextRetainer({ kind: 'head', maxBytes: 3 }) + r.push('a😀') // bytes: 61 F0 9F 98 80 + const result = r.finish() + expect(result.text).toBe('a') + expect(result.text).not.toContain('�') + }) + + it('keeps a whole 4-byte codepoint that fits exactly', () => { + const r = new TextRetainer({ kind: 'head', maxBytes: 4 }) + r.push('😀x') + expect(r.finish().text).toBe('😀') + }) + + it('leaves a head cut ending on a stray continuation run untouched', () => { + // A cut whose trailing bytes are ALL continuation bytes with no lead in + // reach is not a trimmable incomplete sequence — the trimmer bails (no lead + // byte found) and leaves them for the non-fatal decoder to replace. + const r = new TextRetainer({ kind: 'head', maxBytes: 2 }) + // 0x80 0x80 are bare continuation bytes; 'z' follows so the head keeps just + // the two continuation bytes and the cut lands right after them. + r.push(new Uint8Array([0x80, 0x80, 0x7a])) + const result = r.finish() + // The trimmer did not throw and did not eat the bytes as a partial sequence; + // only the trailing 'z' is omitted by the 2-byte cap. + expect(result.omittedBytes).toEqual({ kind: 'exact', count: 1 }) + }) + + it('leaves a head cut ending on an invalid lead byte untouched', () => { + // 0xF8 is not a valid UTF-8 lead byte (only 0x00–0xF7 lead). The trimmer + // recognizes it as "not a lead" (expected length 0) and leaves the byte in + // place rather than trimming a phantom partial sequence. + const r = new TextRetainer({ kind: 'head', maxBytes: 1 }) + r.push(new Uint8Array([0xf8, 0x61])) // 0xF8 kept, 'a' dropped by the 1-byte cap + const result = r.finish() + expect(result.omittedBytes).toEqual({ kind: 'exact', count: 1 }) + }) +}) + +describe('describeOmitted — false precision safety', () => { + it('prints an exact count for exact omission', () => { + expect(describeOmitted({ kind: 'exact', count: 3 }, 'items')).toBe('Omitted 3 items.') + expect(describeOmitted({ kind: 'exact', count: 12 }, 'bytes')).toBe('Omitted 12 bytes.') + }) + + it('prints NO count for unknown omission', () => { + expect(describeOmitted({ kind: 'unknown' }, 'lines')).toBe('More lines were omitted.') + }) + + it('returns empty string when nothing was omitted', () => { + expect(describeOmitted({ kind: 'none' }, 'chars')).toBe('') + }) +}) + +describe('formatRetentionNotice', () => { + const notice = (omitted: Omitted): RetentionNotice => ({ + scope: 'grep', + strategy: 'head', + unit: 'items', + limit: 100, + kept: 100, + omitted, + }) + + it('joins the standardized omission clause with the tool recovery guidance', () => { + const out = formatRetentionNotice( + notice({ kind: 'exact', count: 25 }), + ({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`, + ) + expect(out).toBe('Omitted 25 items. Results capped at 100. Narrow the pattern, path, or include to see more.') + }) + + it('omits the empty half when nothing was omitted', () => { + const out = formatRetentionNotice(notice({ kind: 'none' }), () => 'Recovery text.') + expect(out).toBe('Recovery text.') + }) + + it('omits the empty half when the tool supplies no recovery text', () => { + const out = formatRetentionNotice(notice({ kind: 'exact', count: 2 }), () => '') + expect(out).toBe('Omitted 2 items.') + }) + + it('passes the full notice to the recovery builder (limit as a head/tail pair)', () => { + const headTail: RetentionNotice = { + scope: 'bash stdout', + strategy: 'headTail', + unit: 'bytes', + limit: { head: 2_000, tail: 2_000 }, + kept: 4_000, + omitted: { kind: 'exact', count: 500 }, + } + const out = formatRetentionNotice(headTail, n => + typeof n.limit === 'object' ? `Kept ${n.limit.head}B head + ${n.limit.tail}B tail.` : '') + expect(out).toBe('Omitted 500 bytes. Kept 2000B head + 2000B tail.') + }) +}) diff --git a/packages/util/retention/tsconfig.json b/packages/util/retention/tsconfig.json new file mode 100644 index 0000000000..749cb0208e --- /dev/null +++ b/packages/util/retention/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [] +} diff --git a/packages/util/timeout/README.md b/packages/util/timeout/README.md index ff56b65535..61a2e95ed8 100644 --- a/packages/util/timeout/README.md +++ b/packages/util/timeout/README.md @@ -25,12 +25,19 @@ import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/d ## Usage shape -```ts ignore-check +```ts +import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' + +declare function runWork(options: { signal: AbortSignal }): Promise + // Scope-lifetime consumer (foreground bash, one fetch): `using` disposes the timer. -using d = deadline(upstream, timeoutMs, 'BASH_TIMEOUT') -const outcome = await runWork({ signal: d.signal }) // work listens on d.signal and terminates itself -const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined // classify the first abort, scoped to OUR code -const aborted = d.signal.aborted && !timedOut // mutually exclusive: timeout won, or cancel did +export async function runWithDeadline(upstream: AbortSignal | undefined, timeoutMs: number): Promise { + using d = deadline(upstream, timeoutMs, 'BASH_TIMEOUT') + const outcome = await runWork({ signal: d.signal }) // work listens on d.signal and terminates itself + const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined // classify the first abort, scoped to OUR code + const aborted = d.signal.aborted && !timedOut // mutually exclusive: timeout won, or cancel did + return { outcome, timedOut, aborted } +} ``` The signal only *notifies* — the caller MUST attach its own termination (`d.signal.addEventListener('abort', kill)`, or hand `d.signal` to `fetch`). Racing a promise against a timer would resolve the tool-call while the child process or socket leaks on; handing out a signal forces a real termination path to exist. diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index f6b5791f0f..23ed7157a2 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -35,6 +35,8 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-spill-local": "workspace:^", + "@deepseek-ai/dsh-spill-policy": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", diff --git a/packages/web/tool-web/tests/spill.spec.ts b/packages/web/tool-web/tests/spill.spec.ts new file mode 100644 index 0000000000..58599d2c54 --- /dev/null +++ b/packages/web/tool-web/tests/spill.spec.ts @@ -0,0 +1,95 @@ +/** + * Showcase integration: the real `web_fetch` tool + the real spill stack + * (`dsh-spill-local` backend + `dsh-spill-policy`), exercised through + * `ctx.tools.execute()`. Proves the RFC's default local-backend path — a large + * formatted fetch result is automatically retained and spilled with NO + * tool-specific spill code, and the model-facing text changes ONLY by the + * deliberate spill notice (the full formatted result lands in the spill file). + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import { AddressInfo } from 'node:net' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import WebService from '@deepseek-ai/dsh-web' +import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local' +import LocalSpillStore from '@deepseek-ai/dsh-spill-local' +import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy' +import * as ToolWeb from '@deepseek-ai/dsh-tool-web' + +type Handler = (req: IncomingMessage, res: ServerResponse) => void + +let server: Server +let base: string +let handler: Handler +let spillRoot: string +let ctx: Context + +const BODY = 'X'.repeat(4000) // formatted result is well over the policy cap +const MAX_INLINE_BYTES = 1000 // leaves room for a head/tail preview beside the notice + +beforeEach(async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end(BODY) } + server = createServer((req, res) => { handler(req, res) }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + spillRoot = mkdtempSync(join(tmpdir(), 'dsh-spill-web-')) + + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(WebService, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID }) + // Provider cap generous so the tool returns a large formatted result; the + // policy cap is what triggers the spill (the RFC's separation of concerns). + await ctx.plugin(WebFetchLocal, { maxBodyChars: 500_000 }) + await ctx.plugin(LocalSpillStore, { root: spillRoot }) + await ctx.plugin(SpillPolicy, { maxInlineBytes: MAX_INLINE_BYTES }) + await ctx.plugin(ToolWeb) +}) + +afterEach(async () => { + await new Promise(resolve => server.close(() => { resolve() })) + rmSync(spillRoot, { recursive: true, force: true }) +}) + +/** A web_fetch call carrying a session owner (so the policy can scope the spill). */ +function fetchCall(): Promise<{ isError: boolean; content: { type: string; text?: string }[] }> { + const agent = { session: { header: { id: SessionId('web-sess') } } } + const exec = { callId: CallId('call-1'), name: 'web_fetch', arguments: { url: base }, agent } as unknown as ToolExecution + return ctx.tools.execute(exec) +} + +describe('web_fetch spill showcase', () => { + it('spills a large formatted result and returns a preview + spill locator', async () => { + const out = await fetchCall() + expect(out.isError).toBe(false) + const text = out.content.map(b => b.text).join('') + + // Model-facing text is a preview + notice within the cap, NOT the full body. + expect(text.length).toBeLessThan(BODY.length) + expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(MAX_INLINE_BYTES) + expect(text).toContain(`Fetched ${base}`) // the head of the formatted result survives + expect(text).toContain('Full formatted result stored at:') + expect(text).toContain('Use read with offset/limit, or grep this path') + + // The spill file holds the FULL formatted result the tool returned. + const match = /stored at: (\S+?)\. Use read/.exec(text) + expect(match).not.toBeNull() + const spillPath = match![1]! + const saved = readFileSync(spillPath, 'utf8') + // The provider cap was generous, so the tool did not truncate: the spill file + // holds the full formatted result (header + the complete body), far larger + // than the model-facing preview. + expect(saved).toContain('(HTTP 200)') + expect(saved).toContain(BODY) + expect(saved.length).toBeGreaterThan(text.length) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b12699ad8..f835f475ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -284,6 +284,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: @@ -482,6 +528,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) @@ -504,6 +553,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 @@ -537,6 +589,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) @@ -595,6 +650,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) @@ -688,6 +746,43 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/fs/tool-fs-search: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../../bash/bash + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../../bash/bash-local + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-retention': + specifier: workspace:^ + version: link:../../util/retention + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-spill': + specifier: workspace:^ + version: link:../../spill/spill + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/guard/repeat-tool-guard: dependencies: schemastery: @@ -831,7 +926,7 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: ^0.79.1 - version: 0.79.3(ws@8.21.0)(zod@4.4.3) + version: 0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -1103,6 +1198,71 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/spill/spill: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/spill/spill-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-spill': + specifier: workspace:^ + version: link:../spill + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/spill/spill-policy: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-retention': + specifier: workspace:^ + version: link:../../util/retention + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-spill': + specifier: workspace:^ + version: link:../spill + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/subagent/subagent: devDependencies: '@deepseek-ai/dsh-agent': @@ -1758,6 +1918,18 @@ 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/retention: + devDependencies: + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/util/timeout: devDependencies: cordis: @@ -1779,6 +1951,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-spill-local': + specifier: workspace:^ + version: link:../../spill/spill-local + '@deepseek-ai/dsh-spill-policy': + specifier: workspace:^ + version: link:../../spill/spill-policy '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -2053,6 +2231,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 @@ -2173,6 +2354,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 @@ -3101,6 +3285,10 @@ packages: cpu: [x64] os: [win32] + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -6179,11 +6367,11 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@earendil-works/pi-ai@0.79.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 @@ -6341,12 +6529,14 @@ snapshots: '@exodus/bytes@1.15.1': {} - '@google/genai@1.52.0': + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.7.0 p-retry: 4.6.2 protobufjs: 7.6.4 ws: 8.21.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) transitivePeerDependencies: - bufferutil - supports-color @@ -6605,6 +6795,9 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.20.0': optional: true + '@pkgjs/parseargs@0.11.0': + optional: true + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -8076,6 +8269,8 @@ snapshots: jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 jiti@2.7.0: {} 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/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 72d2d74533..f8a39137aa 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1370, + "AGENTS.md": 1500, "docs/AGENTS.md": 1100, "docs/architecture.md": 1790, "docs/cordis-primer.md": 600, diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 31df485e1d..0a1b450b2e 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -67,6 +67,7 @@ const GROUP_ORDER = [ 'tasks', 'workflow', 'web', + 'spill', 'todo', 'cordis', 'hooks', @@ -106,9 +107,9 @@ const SERVICE_ROLES: ServiceRole[] = [ { key: 'sessionQuery', pkg: 'session-query', - title: 'Exact session-history reads', + title: 'Exact session-history reads and traces', mode: 'seam', - note: 'Resolves live and optional persisted logs into one logical corpus for exact reads.', + note: 'Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces.', }, { key: 'systemPrompt', @@ -250,6 +251,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-web'], note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.', }, + { + key: 'spillStore', + pkg: 'spill', + title: 'Spill storage seam', + mode: 'seam', + implementations: ['spill-local'], + consumers: ['spill-policy'], + note: 'The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill.', + }, { key: 'workflows', pkg: 'workflow', @@ -860,7 +870,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 +898,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/gen-module-graph.ts b/scripts/gen-module-graph.ts index 6ae5dd3044..520375c9ae 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -27,6 +27,7 @@ const GROUP_ORDER = [ 'compact', 'subagent', 'web', + 'spill', 'timeout', 'todo', 'cordis', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 3e3a0ce81b..4d0d03427e 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -27,6 +27,7 @@ import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' import * as ToolSkill from '@deepseek-ai/dsh-tool-skill' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' @@ -149,6 +150,23 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.', }, + { + pkg: '@deepseek-ai/dsh-tool-fs-search', + dir: 'tool-fs-search', + source: 'packages/fs/tool-fs-search/src/index.ts', + requires: ['ctx.tools', 'ctx.bash', 'ctx.systemPrompt'], + writes: ['tool/call', 'tool/result'], + async mount(ctx) { + // The tools inject `bash` (search executes fixed `rg` commands through + // the executor seam, not ctx.fs); boot the local executor to satisfy it. + // `ctx.spillStore` is optional (read via ctx.get) and does not affect the + // schemas, so no spill backend is mounted. + await ctx.plugin(LocalBashExecutor) + await ctx.plugin(ToolFsSearch) + }, + note: + 'glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', + }, { pkg: '@deepseek-ai/dsh-tool-skill', dir: 'tool-skill', 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..7530d07cf7 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" }, @@ -49,9 +51,13 @@ { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageNode", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageTrace", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryErrorCode", "source": "packages/session-query/session-query/src/config.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTraceRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTrace", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" }, @@ -60,6 +66,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 +121,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" }, @@ -149,6 +157,12 @@ { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" }, { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/spill.md", "symbol": "SaveTextSpill", "source": "packages/spill/spill/src/types.ts" }, + { "doc": "docs/core-data-structures/spill.md", "symbol": "SpillOwner", "source": "packages/spill/spill/src/types.ts" }, + { "doc": "docs/core-data-structures/spill.md", "symbol": "SpillSource", "source": "packages/spill/spill/src/types.ts" }, + { "doc": "docs/core-data-structures/spill.md", "symbol": "SpillRef", "source": "packages/spill/spill/src/types.ts" }, + { "doc": "docs/core-data-structures/spill.md", "symbol": "SpillLocator", "source": "packages/spill/spill/src/types.ts" }, + { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowStartRequest", "source": "packages/workflow/workflow/src/types.ts" }, { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" }, { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowResult", "source": "packages/workflow/workflow/src/types.ts" }, diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index bfcb6f684e..e5d1b140a8 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.', } /** @@ -54,6 +55,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, + 'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' }, + 'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' }, 'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates parent-model rendering to dsh-tool-subagent.' }, 'packages/subagent/subagent-subprocess': { kind: 'indirect', reason: 'Only process-based subagent backends compose a child model request.' }, 'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' }, @@ -69,6 +72,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/ui/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' }, 'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' }, 'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' }, + 'packages/util/retention': { kind: 'indirect', reason: 'Only retention consumers render retained content and omission metadata.' }, 'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' }, 'packages/web/web-fetch-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' }, 'packages/web/web-search-exa': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index cf45d66cca..53f69b2cf5 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", @@ -53,6 +54,7 @@ "./packages/tasks/*/src", "./packages/workflow/*/src", "./packages/web/*/src", + "./packages/spill/*/src", "./packages/timeout/*/src", "./packages/todo/*/src", "./packages/cordis/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index edb1479088..f8ad4f99f1 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -11,7 +11,9 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, + { "path": "./packages/util/retention" }, { "path": "./packages/llm/llm" }, { "path": "./packages/core/session" }, { "path": "./packages/core/scope" }, @@ -30,6 +32,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" }, @@ -48,12 +51,16 @@ { "path": "./packages/fs/fs-local" }, { "path": "./packages/fs/fs-policy" }, { "path": "./packages/fs/tool-fs" }, + { "path": "./packages/fs/tool-fs-search" }, { "path": "./packages/web/web" }, { "path": "./packages/web/web-search-exa" }, { "path": "./packages/web/web-search-perplexity" }, { "path": "./packages/web/web-search-deepseek" }, { "path": "./packages/web/web-fetch-local" }, { "path": "./packages/web/tool-web" }, + { "path": "./packages/spill/spill" }, + { "path": "./packages/spill/spill-local" }, + { "path": "./packages/spill/spill-policy" }, { "path": "./packages/timeout/timeout-policy" }, { "path": "./packages/support/invariants" }, { "path": "./packages/support/agent-loop-testkit" }, diff --git a/tsconfig.json b/tsconfig.json index 5a09464252..7ab3c01e64 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,7 +22,9 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, + { "path": "./packages/util/retention" }, { "path": "./packages/llm/llm" }, { "path": "./packages/core/session" }, { "path": "./packages/core/scope" }, @@ -41,6 +43,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" }, @@ -57,6 +60,7 @@ { "path": "./packages/fs/fs-local" }, { "path": "./packages/fs/fs-policy" }, { "path": "./packages/fs/tool-fs" }, + { "path": "./packages/fs/tool-fs-search" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, { "path": "./packages/web/web" }, @@ -65,6 +69,9 @@ { "path": "./packages/web/web-search-deepseek" }, { "path": "./packages/web/web-fetch-local" }, { "path": "./packages/web/tool-web" }, + { "path": "./packages/spill/spill" }, + { "path": "./packages/spill/spill-local" }, + { "path": "./packages/spill/spill-policy" }, { "path": "./packages/timeout/timeout-policy" }, { "path": "./packages/support/invariants" }, { "path": "./packages/support/agent-loop-testkit" },