diff --git a/.gitignore b/.gitignore index 90709e81c0..bb700f23e5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ examples/*/*.jsonl examples/*/.sessions/ coverage/ .doc-typecheck-*/ +.node-next-types-*/ .humanize/ tmp/ .claude/commands/ diff --git a/AGENTS.md b/AGENTS.md index 339eef7bb2..21e9877e05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,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 bd0ca4e888..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 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index af4beab19e..9c650c9df1 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -24,8 +24,11 @@ flowchart LR svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] + pkg_tool_bash["tool-bash"] + pkg_hooks_claude["hooks-claude"] + pkg_hooks_codex["hooks-codex"] 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"] @@ -33,7 +36,6 @@ flowchart LR pkg_tool_web["tool-web"] svc_tools["ctx.tools
Tool registry and guarded execution pipeline"] pkg_tool_ask_user["tool-ask-user"] - pkg_tool_bash["tool-bash"] pkg_tool_cordis["tool-cordis"] pkg_tool_skill["tool-skill"] pkg_tool_subagent["tool-subagent"] @@ -51,8 +53,7 @@ flowchart LR svc_bash["ctx.bash
Bash executor seam"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] - pkg_hooks_claude["hooks-claude"] - pkg_hooks_codex["hooks-codex"] + svc_bashEnv["ctx.bashEnv
Managed bash environment registry"] pkg_sandbox["sandbox"] svc_sandbox["ctx.sandbox
Process-sandbox seam"] pkg_sandbox_local["sandbox-local"] @@ -84,6 +85,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 +121,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 @@ -124,6 +131,7 @@ flowchart LR pkg_subagent_spawn --> svc_subagents pkg_system_prompt --> svc_systemPrompt pkg_tasks --> svc_tasks + pkg_tool_bash --> svc_bashEnv pkg_tools --> svc_tools pkg_user_interaction --> svc_userInteraction pkg_web --> svc_web @@ -153,7 +161,10 @@ flowchart LR svc_sandbox --> pkg_bash_sandbox svc_sessionPersistence --> pkg_acp svc_sessionPersistence --> pkg_agent_loop + svc_sessionPersistence --> pkg_hooks_claude + svc_sessionPersistence --> pkg_hooks_codex svc_sessionPersistence --> pkg_session_query + svc_sessionPersistence --> pkg_tool_bash svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_invariants @@ -161,6 +172,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 @@ -191,8 +203,8 @@ 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.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), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`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 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. | @@ -200,6 +212,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | +| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | | `ctx.approval` | `seam` | `approval` | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools), [`tool-bash`](../packages/bash/tool-bash) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | | `ctx.permission` | `core` | [`permission`](../packages/ui/permission) | - | [`acp`](../packages/ui/acp) | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. | @@ -209,6 +222,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 c94b5d571e..d3136ba5ab 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -48,6 +48,8 @@ export interface Config { toolOrder?: string[] /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */ tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string /** 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`. */ @@ -97,13 +99,14 @@ 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`), - * `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. + * `dshHome` to bash environment and local skill discovery, `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`). */ @@ -114,6 +117,8 @@ export interface Config { toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ tools?: ToolsConfig + /** DeepSeek Harness home directory shared by shell context and local skill discovery. */ + dshHome?: string /** 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. */ @@ -137,7 +142,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) · [`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) +Source: [`packages/examples/agent-spine-demo/src/index.ts:57`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -309,7 +314,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude/src/index.ts:43`](../packages/hooks/hooks-claude/src/index.ts) +Source: [`packages/hooks/hooks-claude/src/index.ts:44`](../packages/hooks/hooks-claude/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` @@ -334,7 +339,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:41`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-jsonrpc` @@ -595,7 +600,7 @@ export interface Config { } ``` -Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:23`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) +Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:24`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` @@ -630,14 +635,14 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:38`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:39`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query` 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 @@ -674,7 +679,41 @@ export interface Config { } ``` -Source: [`packages/skill/skill-local/src/index.ts:39`](../packages/skill/skill-local/src/index.ts) +Source: [`packages/skill/skill-local/src/index.ts:40`](../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` @@ -714,6 +753,8 @@ export interface Config { toolOrder?: string[] /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */ tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ @@ -872,33 +913,35 @@ Source: [`packages/core/system-prompt/src/index.ts:143`](../packages/core/system ## `@deepseek-ai/dsh-time-context` -Requires: `systemPrompt` +Requires: `agents` ```ts config-catalog -/** Request-time clock formatting and refresh policy. Invalid values fail plugin load. */ +/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ export interface Config { /** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */ timeZone?: string - /** Maximum age of a reading within one turn, in milliseconds (default 60,000; `0` refreshes every step). */ + /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible pre-step attempt. */ refreshIntervalMs?: number } ``` -Source: [`packages/context/time-context/src/index.ts:22`](../packages/context/time-context/src/index.ts) +Source: [`packages/context/time-context/src/index.ts:20`](../packages/context/time-context/src/index.ts) ## `@deepseek-ai/dsh-tool-bash` Requires: `tools` · `bash` · `systemPrompt` ```ts config-catalog -/** Configures whether the model may background commands. */ +/** Configuration for the bash tool and its managed child environment. */ export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string } ``` -Source: [`packages/bash/tool-bash/src/index.ts:30`](../packages/bash/tool-bash/src/index.ts) +Source: [`packages/bash/tool-bash/src/index.ts:39`](../packages/bash/tool-bash/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -938,6 +981,28 @@ export interface Config { Source: [`packages/fs/tool-fs/src/index.ts:22`](../packages/fs/tool-fs/src/index.ts) +## `@deepseek-ai/dsh-tool-fs-search` + +Requires: `tools` · `systemPrompt` · `bash` + +```ts config-catalog +/** Plugin config (all optional — `Config` supplies the defaults). */ +export interface Config { + /** Max paths one `glob` call retains inline; later paths go to the formatted spill file. */ + globMaxResults?: number + /** Max flat matches one `grep` call retains inline; later matches go to the formatted spill file. */ + grepMaxMatches?: number + /** Max bytes retained for one matched-line preview (the cut preserves UTF-8 boundaries). */ + grepMaxLineBytes?: number + /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ + rawOutputMaxBytes?: number + /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */ + timeoutMs?: number +} +``` + +Source: [`packages/fs/tool-fs-search/src/index.ts:59`](../packages/fs/tool-fs-search/src/index.ts) + ## `@deepseek-ai/dsh-tool-skill` Requires: `tools` · `skills` @@ -1296,6 +1361,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) @@ -1304,13 +1370,16 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/create-sdk` ([`packages/sdk/create-sdk/src/index.ts`](../packages/sdk/create-sdk/src/index.ts)) - `@deepseek-ai/dsh-acp-snapshot` ([`packages/support/acp-snapshot/src/index.ts`](../packages/support/acp-snapshot/src/index.ts)) +- `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts)) - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) +- `@deepseek-ai/dsh-home` ([`packages/util/home/src/index.ts`](../packages/util/home/src/index.ts)) - `@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/services.md b/docs/cordis-catalog/services.md index 69fa8053ce..a5b0e7ce5d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -71,7 +71,21 @@ abstract start(spec: BashExecSpec): BashProcess Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) -Source: [`packages/bash/bash/src/index.ts:46`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:49`](../../packages/bash/bash/src/index.ts) + +## `ctx.bashEnv` — `BashEnvRegistry` + +Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model bash call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal. + +```ts cordis-catalog +register(contributor: BashEnvContributor): () => void +collect(execution: ToolExecution): DshEnvironment +list(): BashEnvVariableInfo[] +``` + +Types: [ToolExecution](../core-data-structures/tools.md) + +Source: [`packages/bash/tool-bash/src/index.ts:102`](../../packages/bash/tool-bash/src/index.ts) ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam) @@ -163,6 +177,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts:111`](../../packages/sandbox/san Durable append-only session storage. Implementations preserve contiguous, losslessly JSON-serializable events; append resolves only after durability, and load balances a complete interrupted tail without rewriting committed events. ```ts cordis-catalog +abstract locate(meta: SessionHeader): SessionLocation | undefined abstract create(meta: SessionHeader): Promise abstract append(id: SessionId, events: readonly SessionEvent[]): Promise abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> @@ -171,19 +186,21 @@ abstract list(): Promise Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:30`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:42`](../../packages/session-persistence/session-persistence/src/index.ts) ## `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` @@ -202,7 +219,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:580`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:540`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` @@ -217,6 +234,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. diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 3f2602e613..c61c24add6 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -4,9 +4,21 @@ The bash execution seam is split across interface ([dsh-bash](../../packages/bas Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) +## Managed shell environment namespace + +`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; executors remove inherited `DSH_*` names before merging the current snapshot. + +```ts type-equiv +type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` +``` + +```ts type-equiv +type DshEnvironment = Readonly> +``` + ## 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 +27,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 /** @@ -26,15 +45,20 @@ interface BashExecRequest { */ stdin?: string | undefined /** - * Extra environment entries for the command, merged AFTER the - * implementation's credential scrub (so an explicit entry here is honored even - * when its name matches the scrub pattern — the caller named a value it holds, - * not the harness's ambient secret). Set by in-process plugins (the hooks - * bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing - * bash tool does not expose it as a parameter (a model that needs an env var - * uses shell syntax like `FOO=bar cmd`). + * Ordinary environment entries for the command, merged after the credential + * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it + * here. Set by in-process plugins (the hooks bridges set + * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool + * does not expose it as a parameter. */ env?: Record | undefined + /** + * Harness-owned `DSH_*` variables for this execution. Executors discard + * ambient `DSH_*` entries before merging this snapshot, so an unavailable + * current fact cannot inherit a stale value from the harness process, and + * reject non-`DSH_*` names supplied through this managed channel. + */ + dshEnv?: DshEnvironment | undefined /** * Explicit per-call sandbox-policy input, overriding the executor's * configured default mode for THIS call. Never a silent default: a @@ -57,6 +81,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 /** @@ -73,6 +102,8 @@ interface BashExecSpec { * config default, absent means "no extra env". */ env?: Record | undefined + /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + dshEnv?: DshEnvironment | undefined /** * The sandbox mode this call executes under, required-but-nullable so every * resolved spec states its policy. A sandboxing executor's `resolve()` stamps @@ -88,6 +119,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 985c398f61..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. diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 7bf102924b..c1e02ae743 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -2,7 +2,7 @@ The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog.md). -The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md). +The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining locate/create/append/load/list over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md). ## The flush checkpoint @@ -12,6 +12,19 @@ The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06 A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `turn/end`. It does **not** truncate — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. Instead it closes the orphaned turn with a synthetic `turn/end { reason: { kind: 'interrupted' } }`, keeping the log balanced and the turn-enclosure invariant intact. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md](session.md#why-a-turn-ended-turnendreasonmap)). +## `SessionLocation` — optional per-session artifact target + +`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns its absolute target path; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. + +```ts type-equiv +interface SessionLocation { + /** Backend-specific artifact kind, for example `jsonl`. */ + readonly kind: string + /** Absolute path to this session's backend-owned artifact. */ + readonly path: string +} +``` + ## `SessionHeader` — metadata beside the log Per-session metadata travels **separately** from the event log: format version, cwd, lineage, and the seed boundary are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`. @@ -80,7 +93,7 @@ Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resumi ## The backends -Both implement the same abstract `SessionPersistence` (create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: +Both implement the same abstract `SessionPersistence` (locate/create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: - **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path. - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. 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/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/event-producer-consumer.md b/docs/event-producer-consumer.md index 0e4f370267..225431c8b6 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -10,7 +10,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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/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), [`time-context`](../packages/context/time-context), [`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`) | - | @@ -37,7 +37,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:98`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`workspace-context`](../packages/context/workspace-context) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:98`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:80`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 10a02651da..e21e05d6a9 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -9,7 +9,9 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri flowchart TD subgraph group_util["packages/util"] pkg_brand["brand"] + pkg_home["home"] pkg_paths["paths"] + pkg_retention["retention"] pkg_timeout["timeout"] end subgraph group_llm["packages/llm"] @@ -36,6 +38,7 @@ flowchart TD pkg_fs_local["fs-local"] pkg_fs_policy["fs-policy"] pkg_tool_fs["tool-fs"] + pkg_tool_fs_search["tool-fs-search"] end subgraph group_skill["packages/skill"] pkg_skill["skill"] @@ -63,6 +66,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 @@ -87,6 +95,7 @@ flowchart TD end subgraph group_support["packages/support"] pkg_acp_snapshot["acp-snapshot"] + pkg_agent_loop_testkit["agent-loop-testkit"] pkg_invariants["invariants"] pkg_llm_replay["llm-replay"] pkg_loader_smoke["loader-smoke"] @@ -164,6 +173,7 @@ flowchart TD pkg_fs_local --> pkg_fs pkg_fs_policy --> pkg_fs pkg_skill_local --> pkg_fs + pkg_skill_local --> pkg_home pkg_skill_local --> pkg_skill pkg_compact --> pkg_llm pkg_compact --> pkg_session @@ -172,6 +182,9 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_web_search_exa --> pkg_web pkg_web_search_perplexity --> pkg_web + pkg_spill --> pkg_brand + pkg_spill --> pkg_llm + pkg_spill --> pkg_session pkg_session_persistence --> pkg_session pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session @@ -183,6 +196,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 @@ -205,7 +219,6 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_llm pkg_time_context --> pkg_agent - pkg_time_context --> pkg_system_prompt pkg_tasks --> pkg_agent pkg_tasks --> pkg_brand pkg_tasks --> pkg_session @@ -240,8 +253,10 @@ flowchart TD pkg_agent_loop --> pkg_tools pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_bash + pkg_tool_bash --> pkg_home pkg_tool_bash --> pkg_llm pkg_tool_bash --> pkg_sandbox + pkg_tool_bash --> pkg_session_persistence pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tasks pkg_tool_bash --> pkg_tools @@ -251,6 +266,13 @@ flowchart TD pkg_tool_fs --> pkg_session pkg_tool_fs --> pkg_system_prompt pkg_tool_fs --> pkg_tools + pkg_tool_fs_search --> pkg_bash + pkg_tool_fs_search --> pkg_llm + pkg_tool_fs_search --> pkg_retention + pkg_tool_fs_search --> pkg_session + pkg_tool_fs_search --> pkg_spill + pkg_tool_fs_search --> pkg_system_prompt + pkg_tool_fs_search --> pkg_tools pkg_tool_skill --> pkg_agent pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill @@ -263,6 +285,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 @@ -275,7 +302,13 @@ flowchart TD pkg_hooks_codex --> pkg_hook_protocol pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session + pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_tools + pkg_agent_loop_testkit --> pkg_agent + pkg_agent_loop_testkit --> pkg_llm + pkg_agent_loop_testkit --> pkg_session + pkg_agent_loop_testkit --> pkg_system_prompt + pkg_agent_loop_testkit --> pkg_tools pkg_acp --> pkg_agent pkg_acp --> pkg_bash pkg_acp --> pkg_llm @@ -327,6 +360,7 @@ flowchart TD pkg_hooks_claude --> pkg_hook_protocol pkg_hooks_claude --> pkg_llm pkg_hooks_claude --> pkg_session + pkg_hooks_claude --> pkg_session_persistence pkg_hooks_claude --> pkg_subagent pkg_hooks_claude --> pkg_tools pkg_subagent_mock --> pkg_agent @@ -339,6 +373,7 @@ flowchart TD pkg_jsonrpc --> pkg_subagent pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop + pkg_agent_spine_demo --> pkg_home pkg_agent_spine_demo --> pkg_invariants pkg_agent_spine_demo --> pkg_llm pkg_agent_spine_demo --> pkg_session @@ -387,7 +422,9 @@ flowchart TD | Package | Group | Depends on | | --- | --- | --- | | [`brand`](../packages/util/brand) | `util` | — | +| [`home`](../packages/util/home) | `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,17 +449,19 @@ flowchart TD | [`bash`](../packages/bash/bash) | `bash` | [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | -| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`skill`](../packages/skill/skill) | +| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`home`](../packages/util/home), [`skill`](../packages/skill/skill) | | [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`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) | @@ -430,7 +469,7 @@ flowchart TD | [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | @@ -438,15 +477,18 @@ flowchart TD | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`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-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`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) | -| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | +| [`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) | @@ -457,10 +499,10 @@ flowchart TD | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`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) | +| [`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), [`session-persistence`](../packages/session-persistence/session-persistence), [`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), [`workspace-context`](../packages/context/workspace-context) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`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) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index dd6275ea48..08bc172ed9 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -79,9 +79,13 @@ 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 | +| [Expose agent session identity and JSONL location to tools and hooks](implemented/feature/2026-07-10-agent-session-identity-and-log-location.md) | 2026-07-10 | | [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 | +| [Durable per-step time context](implemented/feature/2026-07-16-durable-per-step-time-context.md) | 2026-07-16 | ### Simplification @@ -146,8 +150,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-30-bash-stdin-env-trusted-plugin-surface.md b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md index de6428fb64..bc91d425b6 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md @@ -14,9 +14,9 @@ Add `stdin?: string` and `env?: Record` to **both** `BashExecReq Three deliberate choices: -1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly. +1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly. Harness-owned variables use the separate `dshEnv` channel from the [managed environment decision](../feature/2026-07-10-agent-session-identity-and-log-location.md), so ordinary `env` cannot replace them. -2. **`env` merges AFTER the credential scrub, so an explicit caller entry always wins** — even a credential-shaped name. This is correct because the scrub's job is narrow: stop the harness's *ambient* `process.env` credentials from leaking into a spawned command. A caller that explicitly sets a var has named a value it already holds (not the ambient secret), so the scrub is not a constraint on it. `childEnv(extra?)` layers `scrub(process.env)` → `ENV_OVERRIDES` (the model-friendly `TERM=dumb` etc.) → `extra`, last-wins. +2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision reserves `DSH_*`: ambient entries are removed, ordinary `env` cannot set them, and trusted `dshEnv` merges last. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`. 3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`. 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-30-hook-bridges.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md index e5df9c9548..af5c7e928d 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md @@ -12,8 +12,8 @@ The framing that shapes the whole design: **a bridge is a compatibility adapter, Two independent plugins in the `packages/hooks/` group, each a function/namespace plugin (`name`/`inject`/`Config`/`apply`, NO default export — see [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)) injecting only `bash`: -- **`dsh-hooks-claude`** — the CC dialect. Seven of Claude Code's current hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, and `SubagentStop`. Owns CC-shaped per-event stdin payloads (a base of `session_id`/`cwd`/`hook_event_name` plus per-event fields), `CLAUDE_PROJECT_DIR` plus `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. A CC hook's stdin carries a **trailing newline**. -- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. It uses an always-regex matcher, Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no Codex plugin-env injection or config-time placeholder substitution, and no pre-tool approval or rewrite path. A tool call's payload carries the real `tool_name` in the bridge's reduced `tool_input: { command }` shape. +- **`dsh-hooks-claude`** — the CC dialect. Seven of Claude Code's current hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, and `SubagentStop`. Owns CC-shaped per-event stdin payloads (a base of `session_id`/`transcript_path`/`cwd`/`hook_event_name` plus per-event fields), `CLAUDE_PROJECT_DIR` plus `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. `transcript_path` is the persistence locator result or `''`; stdin carries a **trailing newline**. +- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. It uses an always-regex matcher, Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no Codex plugin-env injection or config-time placeholder substitution, and no pre-tool approval or rewrite path. `transcript_path` is the same locator result or `null`; tool payloads carry the real `tool_name` in the reduced `tool_input: { command }` shape. ### Outcome → Decision mapping 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-agent-session-identity-and-log-location.md b/docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md new file mode 100644 index 0000000000..118009dedb --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md @@ -0,0 +1,85 @@ +# RFC: Expose agent session identity and JSONL location to tools and hooks + +Status: implemented + +## Problem + +An agent can identify its workspace through `session.header.cwd`, but a model using bash cannot reliably identify the session that owns the call or the durable transcript that records it. Searching `./.sessions` guesses deployment config and JSONL layout; custom roots, alternate persistence backends, resume, forks, and concurrent parent/child agents make that guess unreliable. Hooks have the same need for transcript location, while future plugins may need to expose other harness-owned environment facts to shell commands. + +The boundary must preserve two properties: the owner of a fact decides how to resolve it, and every child receives a per-execution snapshot rather than process-global mutable state. In particular, a nested harness must not leak its ambient `DSH_*` values into a child whose current agent, persistence backend, or configuration differs. + +## Decision + +Extend the [`SessionPersistence`](../../implemented/architecture/2026-06-14-session-persistence.md) seam with a synchronous, side-effect-free location query: + +```ts +import type { SessionHeader } from '@deepseek-ai/dsh-session' + +interface SessionLocation { + readonly kind: string + readonly path: string +} + +interface SessionPersistence { + locate(meta: SessionHeader): SessionLocation | undefined +} +``` + +`path` is an absolute local path to the backend's dedicated log for `meta`; `kind` identifies the representation. JSONL returns `{ kind: 'jsonl', path }` using its resolved root and path helpers. SQLite and any backend without an honest local per-session artifact return `undefined`. The query creates and flushes nothing, so it can report a lazy target path before that file exists. + +The model-facing bash package owns a `ctx.bashEnv` registry. A contributor declares its stable name, every `DSH_*` key it may return, a description for each key, and `resolve(execution: ToolExecution)`. Duplicate contributor names, duplicate key ownership, reserved keys, malformed declarations, undeclared runtime output, and non-string output fail loudly. Registration is a Cordis effect and is removed with the contributing plugin fiber. `list()` exposes declarations without running resolvers, keeping the environment surface enumerable for diagnostics and future prompt/UI consumers. + +The registry rebuilds a trusted overlay for every foreground and background bash `ToolExecution`: + +- `DSH_HOME` is always the absolute configured Harness home. The standalone [`@deepseek-ai/dsh-home`](../../../../packages/util/home/README.md) utility owns its precedence: explicit `dshHome`, then ambient `$DSH_HOME`, then `~/.dsh`. +- `DSH_SHELL=1` is always present and identifies a model bash child managed by DeepSeek Harness. +- `DSH_SESSION_ID` is present when the execution has an agent and equals `agent.session.header.id`. +- The built-in persistence translator contributes `DSH_SESSION_JSONL` only when `ctx.sessionPersistence.locate(header)` returns `kind: 'jsonl'`. + +Session persistence remains the fact owner: JSONL does not depend on tool-bash or register shell variables itself, and hooks continue to consume `locate()` directly. Tool-bash is the translation layer from the persistence fact into a shell convention. Other plugins that need shell-visible facts depend on the registry and register their own keys; they do not modify `process.env`. + +The bash seam exports `DSH_ENV_PREFIX` as the single namespace source and derives `DshEnvironmentKey` from its `typeof`. Tool-bash derives built-in names and model guidance from that constant, while executors use it for filtering and channel validation. The seam carries the managed overlay separately as `BashExecRequest.dshEnv` / `BashExecSpec.dshEnv`. Ordinary `env` remains the general in-process plugin surface used by hooks, but cannot contain managed keys; symmetrically, `dshEnv` cannot contain ordinary keys. The local executor rejects either wrong channel before spawn, removes every inherited ambient managed key, applies its ordinary scrub/terminal environment/explicit `env`, and finally merges the trusted `dshEnv` snapshot. This guarantees that a missing value means absent now rather than inherited from an outer or previous harness. The model-facing tool still ignores model-supplied `env`/`stdin` arguments. + +The bash tool description teaches only the durable convention: current harness environment facts are available through managed `$DSH_*` variables and may be inspected when needed. It does not enumerate persistence-specific keys or add a permanent system-prompt section. Tool schemas are already logged in request headers and tool output is logged as `tool/result`, so no new session event is required. + +The [Claude Code and Codex hook bridges](../../implemented/feature/2026-06-30-hook-bridges.md) resolve transcript location from the same persistence seam when constructing payloads. Codex uses `transcript_path: string | null`; Claude Code preserves its string field and falls back to `''`. Hook lookup neither materializes nor flushes a session. + +## Peer product findings + +Peer products separate stable identity from physical storage. Codex injects stable `CODEX_THREAD_ID` into spawned shells while recorder and hook surfaces own transcript paths. Claude Code supplies `session_id` and `transcript_path` as structured hook/status input. OpenCode carries identity in structured tool context; Kimi Code expands a session placeholder; Reasonix keeps the active session path on its controller. The portable rule is to inject identity at the invocation boundary, let storage resolve location, and never use a process-global current-session variable in a concurrent harness. + +## Lifecycle and persistence semantics + +A fresh session receives its id before the first turn, so its first bash call can read `DSH_SESSION_ID` and a JSONL target. The JSONL file may still be absent until the first successful turn-end checkpoint, and during an open turn it contains only the last flushed prefix. `DSH_SESSION_JSONL` is a location hint, not an authorization credential or freshness guarantee. + +Resume reuses the loaded header and therefore the same id and location. Fork and spawn create new session ids and locations. Parent and child calls resolve from their own `ToolExecution.agent`; each command receives an immutable snapshot even when calls overlap. A persistence service replacement affects later collections because the translator queries `ctx.get('sessionPersistence')` at execution time; the registry itself is effect-scoped and HMR-safe. + +`dshHome` is session-independent deployment context. Agent-core resolves one value through `@deepseek-ai/dsh-home` and routes it to both tool-bash and local skill discovery; standalone consumers call the same resolver. If top-level `dshHome` and `skills.local.dshHome` are both supplied and resolve differently, composition fails instead of exposing contradictory homes. Persistence may change independently without freezing its facts into the session prefix. + +## Testing + +Unit coverage pins registry declaration validation, effect disposal, per-execution collection, the `dshHome` precedence, and the local executor's `DSH_*` scrub/rebuild order. Request-recording tests cover foreground/background snapshots, no-agent calls, absent/JSONL persistence, ignored model `env`, and parent/child isolation. JSONL/SQLite locator contract tests and both hook bridge suites pin available and unavailable transcript dialects. + +A keyless full-loop integration drives the real agent loop, JSONL persistence, tool-bash, and bash-local on the first turn. The child prints `DSH_HOME`, `DSH_SHELL`, session id, JSONL target, and an inherited stale sentinel; the test verifies current values, absence of the stale variable, pre-flush file absence, and the eventual persisted header. Snapshot coverage pins the generic bash description in the recorded request header. No with-key test is required because the contract is deterministic local execution rather than model choice. + +## Alternatives considered + +**Only an id plus `find`.** Search cannot know a custom root or backend layout and races under multiple sessions. + +**Only an absolute path.** A path can be unavailable, lazy, or representation-specific and is not stable session identity. + +**Global `process.env`.** Concurrent agents would overwrite one another and nested harnesses would inherit stale current-session values. + +**Put persistence instructions in the session prefix.** A session prefix is frozen while the active service can change across HMR or future backend switching; persistence-specific guidance would become stale. + +**A typed waterfall event.** Listeners cannot declare ownership without running, and later listeners can silently overwrite keys. A registry detects key conflicts at registration and remains enumerable. + +**Have each persistence backend register bash env directly.** That reverses the dependency from storage into one consumer and forces bash into deployments that do not use it. `locate()` is also still required by hooks. + +**A model-facing `session_info` tool.** It adds schema and another call while bash already supplies the query surface; the registry generalizes to future environment facts without one tool per fact. + +## Consequences + +Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. JSONL-backed calls get an optional target path; non-file persistence omits it honestly. The complete `DSH_*` namespace inside these children is managed by the harness: ambient values are removed, current trusted values are re-added, and ordinary callers cannot use `env` to bypass ownership checks. + +The namespace is discoverable but not secret. Paths can reveal configured roots, lazy targets can be absent or stale, and a command can override variables inside its own shell syntax. Consumers treat them as correlation and environment facts, verify transcript metadata when attribution matters, and rely on sandbox/filesystem policy rather than variable secrecy for authorization. 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/feature/2026-07-14-time-context-plugin.i18n.yaml b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml index cb5d12c562..c7984ee20a 100644 --- a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.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 -2026-07-14-time-context-plugin.md: 105bf53550f087fdefb1e6fe0ec493f8628d3e18 -2026-07-14-time-context-plugin.zh.md: 60e9004b1453e75e1bcd84870ad7f18d200a95d8 +2026-07-14-time-context-plugin.md: aa24c6246718cfe0bb3ed63d0791cf890514d9fe +2026-07-14-time-context-plugin.zh.md: e939ff1d0cb250f3fa7a7db34b50d92760e3374d diff --git a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md index 105bf53550..aa24c62467 100644 --- a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md +++ b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md @@ -6,6 +6,8 @@ English | [中文](2026-07-14-time-context-plugin.zh.md) ## Problem +The dynamic system-prompt storage and refresh decision in this record is superseded by [Durable per-step time context](2026-07-16-durable-per-step-time-context.md). The opt-in package, zoned formatting, and validation remain; the follow-up owns the current model-visible and durability contract. + An agent request has no live clock unless a deployment puts one in prompt text or gives the model a query tool. Static text becomes stale, while a tool call adds overhead to ordinary reasoning about dates, deadlines, or idle time. Without elapsed time, the model cannot distinguish an immediate follow-up from one sent hours after the preceding message. Prompt assembly can derive both facts per step from durable session timestamps, and request-header logging can record the exact rendered value. Accumulating stale readings in conversation history or waking idle agents would violate the existing request lifecycle. diff --git a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md index 60e9004b14..e939ff1d0c 100644 --- a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md +++ b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md @@ -6,13 +6,15 @@ Status: implemented ## 问题 +本记录中的动态系统提示词存储和刷新决策已由[持久的逐步骤时间上下文](2026-07-16-durable-per-step-time-context.md)取代。需要显式启用的包(package)、分区时间格式和校验仍然保留;后续 RFC 负责当前的模型可见与持久性契约。 + 如果部署方既未在提示词中提供时钟,也未给模型提供查询工具,agent(智能体)请求就无法获得实时准确的时间。静态文本会变得陈旧,而对于日期、截止时间或闲置时长等常规推理,调用工具会增加开销。缺少已经过去的时长时,模型无法区分紧接着发送的消息与上一条消息几小时后才发送的消息。 提示词组装流程可以在每个步骤中根据持久会话时间戳派生这两项信息,请求头日志则可以记录实际渲染的确切值。在会话历史中累积陈旧读数或唤醒空闲 agent 都会违反现有请求生命周期。 ## 决策 -`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 产品分组用于容纳既不定义工具、也不定义服务的有界请求上下文增强。`dsh-agent-spine-demo` 和仓库提供的示例都不会加载该 package;只有当 token 与信息披露成本可接受时,部署方才显式挂载它。 +`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 产品分组用于容纳既不定义工具、也不定义服务的有界请求上下文增强。`dsh-agent-spine-demo` 和仓库提供的示例都不会加载该包;只有当 token 与信息披露成本可接受时,部署方才显式挂载它。 该插件注册顺序值为 10 的全局系统提示词区段 `context:time`,位置在部署方角色设定之后、工具指导之前。对于活跃轮次,它会输出带数字 UTC 偏移和 IANA 时区、形似 ISO 的时间戳,以及从轮次开始前最后一条模型可见消息起算的紧凑整秒时长。未绑定 agent 或 agent 处于空闲状态时,该区段为空。 @@ -46,7 +48,7 @@ agent loop(智能体循环)会在发送前通过 `request/header` 和 `reque - **省略配置时仍默认使用 UTC**——不予采纳,因为显式启用的时钟应跟随部署环境,除非运维方选择 UTC。需要 UTC 的部署仍可配置 `timeZone: UTC`。 - **引入时区探测库**——不予采纳,因为 Node 的 `Intl` 运行时已经能够提供进程的 IANA 时区,而且额外依赖同样无法推断远程用户的时区。 - **在 `dsh-agent-spine-demo` 中挂载插件**——不予采纳,因为时区、信息披露、token 预算和新鲜度都属于部署策略。选择加入能保持默认上下文稳定。 -- **将 package 放入 `core/`**——不予采纳,因为 `core/` 负责产品 API 主干,而该插件是没有服务键的可选叶节点。 +- **将包放入 `core/`**——不予采纳,因为 `core/` 负责产品 API 主干,而该插件是没有服务键的可选叶节点。 ## 后果 diff --git a/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml b/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml new file mode 100644 index 0000000000..a89248ac6f --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 +2026-07-16-durable-per-step-time-context.md: 12d8191eb72b3fabd3164f13a77d9944631b906e +2026-07-16-durable-per-step-time-context.zh.md: 977ffb7276011ac9ae9b8a72a726bb23baba0a2d diff --git a/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.md b/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.md new file mode 100644 index 0000000000..12d8191eb7 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.md @@ -0,0 +1,70 @@ +# RFC: Durable per-step time context + +Status: implemented + +English | [中文](2026-07-16-durable-per-step-time-context.zh.md) + +## Problem + +A request-only clock can tell the model the current time, but replacing that value in the system prompt removes the evidence behind earlier time-sensitive reasoning. Multi-step turns need requests to retain the readings that shaped preceding steps. The request must remain reconstructable after restart, and automatic compaction must account for the same timing context the model receives. + +A process-local refresh cache makes displayed time depend on state that cannot survive resume or be reconstructed from the durable session. Durable interval scheduling can reduce append frequency without introducing that hidden state. + +## Decision + +`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. It registers a prepended `agent/pre-step` listener and, when an injection is due, calls `agent.inject()` for a pre-step attempt whose signal is not already aborted. The injected `context/message` carries source `{ kind: 'plugin', plugin: 'time-context' }` and append surface metadata; a suppressed attempt appends nothing. + +The listener records preparation context before a possible `step/start`. Its prepended registration runs before ordinary automatic compaction listeners, so pressure estimation and any resulting surface rewrite observe a newly appended reading. A later pre-step listener can cancel or fail the attempt before the step opens; the reading remains because the durable log is append-only and this plugin performs no rollback. + +The optional `timeZone` config resolves the Node process's IANA zone once at plugin load when omitted; an explicit value is validated by `Intl.DateTimeFormat`. The timestamp includes the numeric UTC offset and resolved IANA zone. + +The optional `refreshIntervalMs` config is manually validated at plugin load as a non-negative safe integer. Omission or `0` injects on every eligible preparation attempt. A positive value scans the raw session events for the most recent `context/message` with this plugin's source and injects when none exists, wall time moved backward, or the event is at least the configured age. The raw event timestamp governs even after compaction shadows the message, so scheduling persists across turns and process resume without a timer or process-local cache. + +### Text and elapsed baselines + +An injected first-step reading is: + +```text +Time sampled while preparing turn , step 1: +Elapsed since the preceding model-visible message: . +``` + +The baseline is the latest preceding user, assistant, tool-result, context, or steering message. This includes the accepted prompt that opened an ordinary message turn. If no model-visible message exists, the duration is `unavailable`. + +An injected later-step reading is: + +```text +Time sampled while preparing turn , step : +Elapsed since the preceding step context: . +``` + +Their baseline is the durable event timestamp of the preceding time-context message in the same turn. If interval suppression leaves no earlier same-turn reading, the duration is `unavailable`. Duration formatting uses compact whole-second units and clamps backward wall-clock movement to zero. The explicit turn and step make every retained reading attributable to its historical preparation attempt after later turns append more context. + +### Durability and request reconstruction + +Each reading remains a normal surface node until compaction shadows it; positive interval scheduling never removes existing readings. A later request therefore sees the cumulative unshadowed readings that affected earlier preparation and steps, rather than a system-prompt value rewritten in place. + +The plugin contributes nothing to system-prompt assembly. `request/header` and `request/header-delta` contain no time-context text; request reconstruction obtains the complete durable surface prefix at each `step/start`. Readings and requests need not map one-to-one because a failed preparation can leave a reading while interval suppression can prepare a request without appending one. The plugin depends on the agent registry for its lifecycle listener and does not require the system-prompt service at runtime. + +## Testing + +Unit and real-loop tests pin formatting, both elapsed baselines, interval omission and zero, threshold boundaries, cross-turn and per-session scheduling, backward-clock behavior, invalid config, resumed raw-event lookup after compaction, aborted-signal behavior, later-listener cancellation and failure, listener disposal, source and surface metadata, cumulative multi-step visibility, and absence from request headers. A keyless subprocess e2e boots the real Loader and stdio app, drives two turns, and verifies the persisted context events externally. + +## Supersedes + +This decision supersedes the dynamic system-prompt storage and refresh policy in [Optional time-context plugin](2026-07-14-time-context-plugin.md). It keeps the package location, opt-in deployment stance, timestamp formatting, process-zone default, and load-time validation. Durable history replaces the `context:time` prompt section, process-local refresh cache, and request-header deltas; `refreshIntervalMs` controls durable append frequency instead of prompt replacement. + +## Alternatives considered + +- **Keep the dynamic system-prompt section and process-local refresh cache** — rejected because replacement erases earlier readings, cache state is not replayable, and a frozen request envelope would make the value stale for an entire loop instance. +- **Replace the preceding context surface node** — rejected because replacement preserves the old node's position or shadows intervening conversation; neither represents when the new reading became visible. +- **Inject from a background timer** — rejected because idle time has no pending request to consume the value, and timer-driven injection would create durable turns solely to report time passing. +- **Expose time only through a tool** — rejected because ordinary temporal reasoning would require an avoidable tool round trip and would not guarantee a reading before every step. +- **Use `agent/session-prefix`** — rejected because one loop-instance prefix cannot represent distinct step timestamps and does not accumulate historically attributable readings. + +## Consequences + +- Omission or `0` records every eligible preparation attempt; a positive interval reduces append frequency and history growth while preserving durable scheduling across resume. +- Timing context remains append-only until compaction shadows older surface nodes, including a preparation reading left by a later cancellation or failure. +- The first-step duration normally measures from the prompt that opened the turn, while later-step durations measure model and tool processing since the preceding step context. +- An omitted `timeZone` still reflects the deployment process rather than a remote user, and elapsed time still uses durable harness append boundaries rather than client-origin timestamps. diff --git a/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md b/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md new file mode 100644 index 0000000000..977ffb7276 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md @@ -0,0 +1,70 @@ +# RFC: 持久的逐步骤时间上下文 + +Status: implemented + +[English](2026-07-16-durable-per-step-time-context.md) | 中文 + +## 问题 + +仅存在于请求中的时钟可以告诉模型当前时间,但在系统提示词中替换这个值会移除先前时效性推理所依据的证据。在包含多个步骤的轮次中,请求需要保留影响先前步骤的读数。系统必须能在重启后重建请求,自动压缩(compaction)也必须核算模型实际收到的同一份时间上下文。 + +进程本地刷新缓存使显示的时间依赖无法在恢复后保留、也无法从持久会话重建的状态。持久的间隔调度可以减少追加频率,而不引入这种隐藏状态。 + +## 决策 + +`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。它注册一个前置的 `agent/pre-step` 监听器,并在需要注入时,为信号尚未取消的预步骤尝试调用 `agent.inject()`。注入的 `context/message` 携带来源 `{ kind: 'plugin', plugin: 'time-context' }` 和追加表层元数据;受间隔抑制的尝试不会追加任何内容。 + +监听器在可能出现的 `step/start` 之前记录准备上下文。它采用前置注册,因此先于普通自动压缩监听器运行,使压力估算和由此产生的表层重写都能观察到新追加的读数。后续预步骤监听器可能在步骤开启前取消尝试或使其失败;持久日志仅追加,且本插件不执行回滚,因此该读数会保留下来。 + +省略可选配置 `timeZone` 时,插件在加载时解析一次 Node 进程的 IANA 时区;显式值由 `Intl.DateTimeFormat` 校验。时间戳包含数字 UTC 偏移和解析后的 IANA 时区。 + +插件在加载时手动校验可选配置 `refreshIntervalMs`,其值必须为非负安全整数。省略或设为 `0` 时,每次符合条件的准备尝试都会注入。设为正数时,插件扫描原始会话事件,查找来源属于本插件的最新 `context/message`;不存在此类事件、系统挂钟向后移动,或该事件已达到配置时长时,插件执行注入。即使压缩已隐藏消息,调度仍以原始事件时间戳为准,因此该机制无需计时器或进程本地缓存,也能跨轮次和进程恢复持续生效。 + +### 文本与时长基线 + +第一个步骤的注入读数为: + +```text +Time sampled while preparing turn , step 1: +Elapsed since the preceding model-visible message: . +``` + +基线是前一条用户消息、助手消息、工具结果、上下文消息或 steering(中途引导)消息。对于普通消息轮次,这包括开启轮次的已接受提示词。如果不存在模型可见消息,时长为 `unavailable`。 + +后续步骤的注入读数为: + +```text +Time sampled while preparing turn , step : +Elapsed since the preceding step context: . +``` + +其基线是同一轮次中上一条时间上下文消息的持久事件时间戳。如果间隔抑制导致同一轮次中没有更早的读数,时长为 `unavailable`。时长采用紧凑的整秒单位,并在系统挂钟向后移动时钳制为零。显式的轮次号和步骤号使每个保留的读数在后续轮次追加更多上下文后,仍可归属于对应的历史准备尝试。 + +### 持久性与请求重建 + +每个读数都作为普通表层节点保留,直至压缩将其隐藏;正数间隔调度绝不会移除已有读数。因此,后续请求会看到影响先前准备过程和步骤且尚未被隐藏的累计读数,而不是一个被原地改写的系统提示词值。 + +插件不向系统提示词组装贡献任何内容。`request/header` 和 `request/header-delta` 不包含时间上下文文本;请求重建从每个 `step/start` 取得完整的持久表层前缀。读数与请求无需一一对应,因为失败的准备过程可能留下读数,而间隔抑制也可能使请求准备过程不追加读数。插件通过 agent 注册表使用生命周期监听器,运行时不需要系统提示词服务。 + +## 测试 + +单元测试和真实 agent loop(智能体循环)测试固定格式化、两种时长基线、间隔省略和零值、阈值边界、跨轮次和各会话独立调度、挂钟后退行为、无效配置、压缩后基于恢复会话的原始事件查找、已取消信号行为、后续监听器取消和失败、监听器 dispose(资源释放)、来源与表层元数据、多步骤累计可见性,以及请求头中不存在时间上下文。无密钥子进程 e2e 测试通过真实 Loader 和 stdio 应用启动,驱动两个轮次,并从外部校验持久化的上下文事件。 + +## 取代的决策 + +本决策取代[可选时间上下文插件](2026-07-14-time-context-plugin.md)中的动态系统提示词存储和刷新策略。它保留包位置、选择加入式部署、时间戳格式、进程时区默认值和加载时校验。持久历史取代 `context:time` 提示词区段、进程本地刷新缓存和请求头增量;`refreshIntervalMs` 用于控制持久追加频率,而非提示词替换。 + +## 考虑过的替代方案 + +- **保留动态系统提示词区段和进程本地刷新缓存**——不予采纳,因为替换会抹去先前读数,缓存状态无法回放,而且冻结的请求内容集合会使该值在整个 agent loop 实例期间保持陈旧。 +- **替换前一条上下文表层节点**——不予采纳,因为替换会保留旧节点的位置或隐藏中间的会话内容;两者都不能表达新读数何时开始可见。 +- **通过后台计时器注入**——不予采纳,因为空闲期间没有待处理请求消费该值,而且计时器驱动的注入会仅为报告时间流逝而创建持久轮次。 +- **只通过工具提供时间**——不予采纳,因为普通时间推理会产生本可避免的工具往返,也不能保证每个步骤之前都有读数。 +- **使用 `agent/session-prefix`**——不予采纳,因为一个 loop 实例前缀无法表示不同的步骤时间戳,也不会累计具有历史归属的读数。 + +## 后果 + +- 省略 `refreshIntervalMs` 或设为 `0` 时,每次符合条件的准备尝试都会留下记录;正数间隔会减少追加频率和历史增长,同时使持久调度在恢复后继续生效。 +- 时间上下文仅追加并保留到压缩隐藏旧表层节点为止,其中也包括后续取消或失败所留下的准备读数。 +- 第一个步骤的时长通常从开启轮次的提示词起算,后续步骤的时长则反映自上一条步骤上下文以来的模型与工具处理时间。 +- 省略 `timeZone` 时仍采用部署进程而非远程用户的时区,时长仍采用 harness 的持久追加边界而非客户端来源时间戳。 diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md index f3a7b1e83c..4e27b111d6 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -14,9 +14,9 @@ Flattening those members directly into `lefthook.yml` solves the local hook only [lefthook.yml](../../../../lefthook.yml) keeps one pre-push job named `full check` and runs `pnpm run check:pre-push`. That package script delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), the same bounded scheduler CI uses. -The `pre-push` mode expands into leaf gates for the unit suite, snapshot suite, build, `hygiene` members, `doc-sync` members, and module-graph freshness. The leaf list keeps the same gate vocabulary as the package scripts, including RFC classification and RFC format, while the runner schedules independent checks concurrently and prints one timing/output block per gate. +The `pre-push` mode expands into leaf gates for the unit suite, snapshot suite, build, `hygiene` members, `doc-sync` members, and module-graph freshness. The leaf list keeps the same gate vocabulary as the package scripts, including RFC classification and RFC format, while the runner schedules independent checks with four active top-level workers by default; `DSH_GATE_CONCURRENCY` overrides that bound. -The build gate makes the hook self-contained from a clean worktree. `publint` and `verify-node-next-types` wait for that build output, while source-only gates continue in parallel. +The build gate makes the hook self-contained from a clean worktree. `publint`, `verify-node-next-types`, and the pre-push form of `doc-typecheck` wait for that build output, while source-only gates continue in parallel. [scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers the package list from `packages//` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block. 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..048302db56 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()`. | @@ -126,7 +127,7 @@ Owned by the tool registry as a reserved transport outside filterable capability ### `bash` -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`. +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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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`. ```json { @@ -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/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/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 1844d32f52..5c41f8c9fa 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -53,6 +53,7 @@ const SCENARIOS: Scenario[] = [ // Its prompt and tool-schema sidecars pin the composed header. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, { name: 'tool-call-turn', hasModelTurn: true, recorded: true }, + { name: 'bash-spill', hasModelTurn: true, recorded: false, headerClass: 'fs', configPath: FS_CONFIG }, { name: 'fs-terminal-card', hasModelTurn: true, recorded: true }, { name: 'todo-plan', hasModelTurn: true, recorded: true }, { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' }, diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md index 33bfa25590..9d57691ab4 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md @@ -27,7 +27,7 @@ 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. */ + /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json index daf4e93ee8..e6700c6611 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json @@ -2,7 +2,7 @@ "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.", + "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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": { 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/both-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md index 36aa94c53c..1d6918d142 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md @@ -27,7 +27,7 @@ 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. */ + /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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; diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json index 52e7974409..8cc9f35ea2 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json @@ -2,7 +2,7 @@ "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.", + "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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": { diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md index 36aa94c53c..1d6918d142 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md @@ -27,7 +27,7 @@ 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. */ + /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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; 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 index 65b354efd1..bfedf3ed80 100644 --- 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 @@ -33,7 +33,7 @@ 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. */ + /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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; diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 070f269bcd..feb791e71e 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -131,8 +131,8 @@ {"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[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,83,84,85,86,87,88,89,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],"surfaceOp":"append"} {"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"b76bc320-c954-4f8a-b7d6-30821793dae8","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"b76bc320-c954-4f8a-b7d6-30821793dae8","outcome":"allowed-once"}} +{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"f2837399-691e-4913-abf4-9cd40aa31ac2","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"f2837399-691e-4913-abf4-9cd40aa31ac2","outcome":"allowed-once"}} {"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"} {"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index c0315ac54a..c04cfdb6c6 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -155,8 +155,8 @@ {"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[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,83,84,85,86,87,88,89,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],"surfaceOp":"append"} {"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"66efb593-279a-472b-b647-c50d34045bc0","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"66efb593-279a-472b-b647-c50d34045bc0","outcome":"rejected"}} +{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"e5a72cf8-322c-4ec1-a082-55903876dc53","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"e5a72cf8-322c-4ec1-a082-55903876dc53","outcome":"rejected"}} {"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"} {"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index f67bf420ab..7659b738d2 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -55,8 +55,8 @@ {"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"af257151-c371-4be2-a9ab-fbf4b6d18eb1","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"af257151-c371-4be2-a9ab-fbf4b6d18eb1","outcome":"rejected"}} +{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"6f7aaf62-f3cf-435f-8338-e8de72dbfbbf","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"6f7aaf62-f3cf-435f-8338-e8de72dbfbbf","outcome":"rejected"}} {"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json index 4b07d8edef..c1da2056fa 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json @@ -2,7 +2,7 @@ "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.", + "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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": { diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json index 4b07d8edef..c1da2056fa 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json @@ -2,7 +2,7 @@ "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.", + "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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": { diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json index 4b07d8edef..c1da2056fa 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json @@ -2,7 +2,7 @@ "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.", + "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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": { 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 index da5e23216c..49b1b21197 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.golden.json @@ -2,7 +2,7 @@ "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.", + "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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": { diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json index da5e23216c..49b1b21197 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json @@ -2,7 +2,7 @@ "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.", + "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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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": { 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 f4e94788c8..60d4293e0f 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -114,3 +114,29 @@ - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' + +# Bash-backed discovery tools (glob/grep): fixed ripgrep commands through the +# local bash executor above — not ctx.fs. Capped results save the complete +# formatted list through the spill backend below (ctx.spillStore, optional). +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + +# The tool-call timeout enforcer: arms each declared ToolDefinition.timeoutMs +# (the search tools above declare 30s) as a deadline on exec.signal. Without +# it a declared budget is advisory and only the bash executor's own timeout +# backstop applies. +- id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' + +# Tool-output spill stack: a local backend that saves oversized tool text under +# a private session-scoped dir, and the tools/post-execute policy that replaces +# an over-budget plain-text result with a preview + the spill locator/retrieval +# hint. A leaf pair after the app (needs ctx.tools). The policy is a no-op until +# a tool returns more than maxInlineBytes of plain text. +- id: spill-local + name: '@deepseek-ai/dsh-spill-local' + +- id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 50000 diff --git a/examples/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts index dd0bc42a1b..22c8c3f662 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/coding-agent/tests/harness.ts @@ -1,11 +1,7 @@ import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' @@ -50,11 +46,9 @@ export interface CodingHarnessOptions { export async function codingHarness(workdir: string, options: CodingHarnessOptions = {}): Promise { const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: options.persona ?? '' }) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx, { + systemPrompt: { persona: options.persona ?? '' }, + }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) diff --git a/examples/cordis-agent/tests/harness.ts b/examples/cordis-agent/tests/harness.ts index 78e5b0bb93..260a68bfbb 100644 --- a/examples/cordis-agent/tests/harness.ts +++ b/examples/cordis-agent/tests/harness.ts @@ -1,10 +1,6 @@ import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' @@ -23,11 +19,9 @@ const PERSONA = 'You are cordis-agent, a self-referential harness demo. ' export async function cordisHarness(): Promise { const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: PERSONA }) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx, { + systemPrompt: { persona: PERSONA }, + }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) await ctx.plugin(ToolCordis) diff --git a/knip.json b/knip.json index 2d0831dce0..9c644cebb7 100644 --- a/knip.json +++ b/knip.json @@ -35,11 +35,21 @@ "project": ["src/**/*.ts"], "ignoreDependencies": ["cordis"] }, + "packages/util/home": { + "entry": ["tests/**/*.spec.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"], + "ignoreDependencies": ["cordis"] + }, "packages/util/timeout": { "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"], "ignoreDependencies": ["cordis"] }, + "packages/util/retention": { + "entry": ["tests/**/*.spec.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"], + "ignoreDependencies": ["cordis"] + }, "packages/support/acp-snapshot": { "entry": ["tests/**/*.spec.ts", "tests/fixtures/fake-acp-agent.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"], @@ -132,6 +142,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 c718f52d9f..6c83abfc91 100644 --- a/packages/README.md +++ b/packages/README.md @@ -13,7 +13,7 @@ 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) | Model-visible request context, including workspace instructions and time context | Product — stable surface | @@ -21,24 +21,25 @@ Packages live at `packages///`; groups are containers, while names r | [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface | | [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, the worker-thread engine, and the model-facing `workflow` tool | Product — stable surface | | [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | -| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface | +| [`spill/`](spill/README.md) | Spill capability family: the storage seam, a local impl, and the tool-result spill policy | Product — stable surface | | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface | +| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface | | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface | | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`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 (invariants, replay, Loader smokes, subagent mock) | Support — lower compatibility expectations | -| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, path helpers) | Support — small, stable, harness-dep-free | +| [`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`, Harness home/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..afa5000767 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -23,8 +23,8 @@ 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. -- **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). +- **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*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.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. ## Model Experience diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 9c2b0b4511..587c33b9a2 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -92,15 +92,22 @@ 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. + // Carry stdin/ordinary env/trusted dshEnv through verbatim — optional, + // no config default. run.ts owns the scrub and merge order. ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, - // Local execution carries this override for sandboxing subclasses. + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + // Carry a sandbox-mode override through verbatim: this executor never + // confines, so the field is inert here (the seam contract) — a + // sandboxing subclass overrides resolve() to stamp its default instead. sandboxMode: request.sandboxMode, } } @@ -111,11 +118,13 @@ 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, env: spec.env, + dshEnv: spec.dshEnv, }, this.internals).done // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined @@ -128,11 +137,13 @@ 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, env: spec.env, + dshEnv: spec.dshEnv, }, this.internals) let stdoutOffset = 0 diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index 02e7a963be..fa4dae73d6 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -11,7 +11,8 @@ import { randomBytes } from 'node:crypto' import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { CollectedOutput } from '@deepseek-ai/dsh-bash' +import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' +import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash' /** * Model-friendly environment overrides: disable colors, pagers, and @@ -34,26 +35,43 @@ export const ENV_OVERRIDES = { export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i /** - * Build a child environment by scrubbing credential-shaped ambient variables, - * applying model-friendly overrides, then merging trusted caller entries last. - * - * @param extra - caller-supplied entries merged last; an explicit entry wins even against the scrub and the overrides. + * Build a child environment from scrubbed ambient values, terminal overrides, + * ordinary caller entries, and a managed `DSH_*` snapshot. Ambient managed + * names are removed; ordinary and managed entries reject the other channel's + * namespace before `dshEnv` merges last. + * @param extra - caller entries; `DSH_*` names are rejected. + * @param dshEnv - managed entries; non-`DSH_*` names are rejected. * @returns the environment to hand to `spawn` for the child process. */ -export function childEnv(extra?: Record): NodeJS.ProcessEnv { +export function childEnv( + extra?: Readonly>, + dshEnv?: DshEnvironment, +): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} for (const [key, value] of Object.entries(process.env)) { - if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value + if (!SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value } - return { ...env, ...ENV_OVERRIDES, ...extra } + for (const key of Object.keys(extra ?? {})) { + if (key.startsWith(DSH_ENV_PREFIX)) { + throw new Error(`ordinary bash env cannot set reserved variable "${key}"; use dshEnv`) + } + } + for (const key of Object.keys(dshEnv ?? {})) { + if (!key.startsWith(DSH_ENV_PREFIX)) { + throw new Error(`managed bash env cannot set ordinary variable "${key}"; use env`) + } + } + return { ...env, ...ENV_OVERRIDES, ...extra, ...dshEnv } } /** What to run and under which limits (resolved — no defaults in here). */ 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 /** @@ -71,12 +89,12 @@ export interface SpawnSpec { */ stdin?: string | undefined /** - * Extra environment entries, merged onto the scrubbed env AFTER the - * credential scrub and the model-friendly overrides (so an explicit entry - * wins). Set by in-process plugins; the model-facing tool does not forward - * model input here. + * Ordinary environment entries merged after the credential scrub and + * terminal overrides. `DSH_*` names are rejected and belong in `dshEnv`. */ env?: Record | undefined + /** Harness-owned entries; non-`DSH_*` names are rejected before spawn. */ + dshEnv?: DshEnvironment | undefined } /** @@ -278,13 +296,13 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB } // Keep absent stdin as /dev/null; literal tuples preserve non-null output types. - const env = childEnv(spec.env) + const env = childEnv(spec.env, spec.dshEnv) const child: ChildProcessByStdio = spec.stdin !== undefined ? 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..9db0c2eebd 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 () => { @@ -110,21 +127,28 @@ describe('LocalBashExecutor.run', () => { await expect(bash.run(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/) }) - it('resolve() carries stdin/env onto the spec, and run() threads them to the command', async () => { + it('resolve() carries stdin/env/dshEnv onto the spec, and run() threads them to the command', async () => { const { bash } = await setup() - const spec = bash.resolve({ command: 'cat; echo "[$DSH_SEAM_VAR]"', stdin: 'piped\n', env: { DSH_SEAM_VAR: 'env-ok' } }) - // resolve() keeps the stdin/env fields verbatim (optional, no default). + const spec = bash.resolve({ + command: 'cat; echo "[$SEAM_VAR][$DSH_SEAM_VAR]"', + stdin: 'piped\n', + env: { SEAM_VAR: 'env-ok' }, + dshEnv: { DSH_SEAM_VAR: 'dsh-ok' }, + }) + // resolve() keeps the optional input/environment fields verbatim. expect(spec.stdin).toBe('piped\n') - expect(spec.env).toEqual({ DSH_SEAM_VAR: 'env-ok' }) + expect(spec.env).toEqual({ SEAM_VAR: 'env-ok' }) + expect(spec.dshEnv).toEqual({ DSH_SEAM_VAR: 'dsh-ok' }) const result = await bash.run(spec) - expect(result.stdout.text).toBe('piped\n[env-ok]\n') + expect(result.stdout.text).toBe('piped\n[env-ok][dsh-ok]\n') }) - it('resolve() omits stdin/env when the request supplies neither', async () => { + it('resolve() omits stdin/env/dshEnv when the request supplies none', async () => { const { bash } = await setup() const spec = bash.resolve({ command: 'true' }) expect('stdin' in spec).toBe(false) expect('env' in spec).toBe(false) + expect('dshEnv' in spec).toBe(false) }) }) @@ -143,11 +167,12 @@ describe('LocalBashExecutor.start (background process handles)', () => { it('threads stdin and extra env into a background process', async () => { const { bash } = await setup() const proc = bash.start(bash.resolve({ - command: 'cat; echo "[$DSH_BG_VAR]"', + command: 'cat; echo "[$BG_VAR][$DSH_BG_VAR]"', stdin: 'bg-stdin\n', - env: { DSH_BG_VAR: 'bg-env' }, + env: { BG_VAR: 'bg-env' }, + dshEnv: { DSH_BG_VAR: 'bg-dsh-env' }, })) - const output = await readUntil(proc, '[bg-env]') + const output = await readUntil(proc, '[bg-env][bg-dsh-env]') expect(output).toContain('bg-stdin') await proc.done expect(proc.exitCode).toBe(0) diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index 923b3adf7b..e65500a4b5 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/bash/bash-local/tests/run.spec.ts @@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' +import type { DshEnvironment } from '@deepseek-ai/dsh-bash' import { killGroup, OutputCollector, runBash } from '../src/run.ts' import type { RunningBash } from '../src/run.ts' @@ -26,7 +27,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, } @@ -197,19 +199,19 @@ describe('stdin and extra env (set by in-process plugins)', () => { expect(piped.stdout.text).toBe('socket\n') }) - it('merges extra env entries onto the scrubbed environment', async () => { - const result = await runBash(spec('echo "$DSH_EXTRA_ONE/$DSH_EXTRA_TWO"', { - env: { DSH_EXTRA_ONE: 'alpha', DSH_EXTRA_TWO: 'beta' }, + it('merges ordinary extra env entries onto the scrubbed environment', async () => { + const result = await runBash(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', { + env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' }, })).done expect(result.stdout.text).toBe('alpha/beta\n') }) it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => { // TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins. - // DSH_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit + // EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit // entry is still honored — the scrub only drops AMBIENT process.env creds. - const result = await runBash(spec('echo "$TERM/$DSH_OVERRIDE_KEY"', { - env: { TERM: 'xterm-256color', DSH_OVERRIDE_KEY: 'explicit-wins' }, + const result = await runBash(spec('echo "$TERM/$EXPLICIT_OVERRIDE_KEY"', { + env: { TERM: 'xterm-256color', EXPLICIT_OVERRIDE_KEY: 'explicit-wins' }, })).done expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n') }) @@ -224,10 +226,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 +258,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 +269,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) @@ -348,13 +364,13 @@ describe('abort edge cases', () => { }) describe('environment and spill-file hardening', () => { - it('scrubs credential-shaped env vars from child processes', async () => { + it('scrubs credential-shaped and ambient DSH env vars from child processes', async () => { process.env.DSH_TEST_API_KEY = 'super-secret' process.env.DSH_TEST_TOKEN = 'also-secret' process.env.DSH_TEST_PLAIN = 'visible' try { const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done - expect(result.stdout.text.trim()).toBe('[absent|absent|visible]') + expect(result.stdout.text.trim()).toBe('[absent|absent|absent]') } finally { delete process.env.DSH_TEST_API_KEY delete process.env.DSH_TEST_TOKEN @@ -362,9 +378,32 @@ describe('environment and spill-file hardening', () => { } }) + it('injects only the current trusted DSH environment after scrubbing ambient values', async () => { + process.env.DSH_STALE = 'old-value' + try { + const result = await runBash(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', { + dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' }, + })).done + expect(result.stdout.text.trim()).toBe('[absent|1|current-session]') + } finally { + delete process.env.DSH_STALE + } + }) + + it('rejects DSH variables on the ordinary env channel', () => { + expect(() => runBash(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } }))) + .toThrow(/DSH_WRONG_CHANNEL.*dshEnv/) + }) + + it('rejects ordinary variables on the managed env channel', () => { + const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment + expect(() => runBash(spec('true', { dshEnv: invalid }))) + .toThrow(/managed bash env.*PATH.*use env/) + }) + 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 +414,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..e4b5bf1952 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -27,11 +27,11 @@ 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?, dshEnv?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, 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). -`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec; a missing value means "none". See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). +`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). ## Model Experience diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 7563224000..75a5f7f230 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -9,6 +9,7 @@ import { Context, Service } from 'cordis' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts' +export { DSH_ENV_PREFIX } from './types.ts' export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' export type { BashExecRequest, @@ -19,6 +20,8 @@ export type { BashRunResult, BashSandboxInfo, CollectedOutput, + DshEnvironment, + DshEnvironmentKey, } from './types.ts' declare module 'cordis' { diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index d9ad6eda6d..6e0ddca91e 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -6,6 +6,15 @@ import type { SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox' +/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ +export const DSH_ENV_PREFIX = 'DSH_' as const + +/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ +export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` + +/** Trusted DeepSeek Harness variables for one bash execution. */ +export type DshEnvironment = Readonly> + /** * Sandbox facts for one run, present iff a sandboxing executor handled it. * Facts are reported independently of process exit status so callers can @@ -34,6 +43,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 /** @@ -45,15 +61,20 @@ export interface BashExecRequest { */ stdin?: string | undefined /** - * Extra environment entries for the command, merged AFTER the - * implementation's credential scrub (so an explicit entry here is honored even - * when its name matches the scrub pattern — the caller named a value it holds, - * not the harness's ambient secret). Set by in-process plugins (the hooks - * bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing - * bash tool does not expose it as a parameter (a model that needs an env var - * uses shell syntax like `FOO=bar cmd`). + * Ordinary environment entries for the command, merged after the credential + * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it + * here. Set by in-process plugins (the hooks bridges set + * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool + * does not expose it as a parameter. */ env?: Record | undefined + /** + * Harness-owned `DSH_*` variables for this execution. Executors discard + * ambient `DSH_*` entries before merging this snapshot, so an unavailable + * current fact cannot inherit a stale value from the harness process, and + * reject non-`DSH_*` names supplied through this managed channel. + */ + dshEnv?: DshEnvironment | undefined /** Explicit per-call sandbox mode override. */ sandboxMode?: SandboxMode | undefined } @@ -67,15 +88,24 @@ 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. */ stdin?: string | undefined /** - * Extra environment entries, merged after credential scrubbing so explicit - * values win; absent means no extra entries. + * Ordinary environment entries carried through from + * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * OPTIONAL on the spec for the same reason as `stdin`: absent means no + * ordinary extra environment. */ env?: Record | undefined + /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + dshEnv?: DshEnvironment | undefined /** Resolved sandbox mode; ignored by executors that do not confine. */ sandboxMode: SandboxMode | undefined } @@ -96,9 +126,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..d5e65f1a39 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -24,6 +24,29 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th `command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer (from the calling agent's `session.header.cwd`) BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. +### Managed shell environment + +Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-home`](../../util/home/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential. + +`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; tool-bash's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam. + +```ts +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-tool-bash' + +export const inject = ['bashEnv'] + +export function apply(ctx: Context): void { + ctx.bashEnv.register({ + name: 'deployment-region', + variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } }, + resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' }, + }) +} +``` + +The overlay is computed from the current `ToolExecution` and passed through the dedicated `BashExecRequest.dshEnv` channel. The local executor removes all inherited `DSH_*` before merging that snapshot, so nested harnesses and concurrent parent/child agents cannot leak stale identities. `process.env` is never modified. The tool description teaches the generic `$DSH_*` convention rather than naming persistence-specific variables or adding a permanent system-prompt section. + Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`. When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. @@ -34,7 +57,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`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). ## Permissions and escalation diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index 035b16aeec..816be1ef88 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -25,7 +25,9 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-home": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", @@ -38,12 +40,16 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 476cd1a176..cad92c9276 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -8,34 +8,203 @@ * @module @deepseek-ai/dsh-tool-bash */ -import type { Context } from 'cordis' +import { Service, type Context } from 'cordis' import z from 'schemastery' import { isAbsolute, resolve as resolvePath } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-session-persistence' import { assertNever } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tasks' import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { effectiveSandboxMode } from '@deepseek-ai/dsh-bash' +import { DSH_ENV_PREFIX, effectiveSandboxMode } from '@deepseek-ai/dsh-bash' +import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' +import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home' import { processOutcome } from './background.ts' import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' +declare module 'cordis' { + interface Context { + bashEnv: BashEnvRegistry + } +} + export const name = 'tool-bash' export const inject = ['tools', 'bash', 'systemPrompt'] -/** Configures whether the model may background commands. */ +/** Configuration for the bash tool and its managed child environment. */ export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ + dshHome?: string } +/** Runtime configuration schema for the bash tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), + dshHome: z.string(), }) +/** Model-visible metadata for one managed `DSH_*` environment variable. */ +export interface BashEnvVariable { + /** Concise description of the environment fact represented by the variable. */ + description: string +} + +/** + * A plugin contribution to the managed environment of each model bash call. + * Declared keys make ownership conflicts detectable before the first command; + * `resolve` computes only the values available for the current execution. + */ +export interface BashEnvContributor { + /** Stable contributor name used in diagnostics and duplicate detection. */ + name: string + /** Complete set of `DSH_*` keys this contributor may return. */ + variables: Readonly> + /** + * Resolve this contributor's available values for one tool execution. + * @param execution - the bash tool execution and its optional calling agent. + * @returns a partial map containing only keys declared in {@link variables}. + */ + resolve(execution: ToolExecution): Readonly>> +} + +/** An enumerable declaration returned by {@link BashEnvRegistry.list}. */ +export interface BashEnvVariableInfo extends BashEnvVariable { + /** Contributor that owns the variable. */ + contributor: string + /** Declared `DSH_*` environment variable name. */ + key: DshEnvironmentKey +} + +const DSH_SHELL_KEY = `${DSH_ENV_PREFIX}SHELL` as const +const DSH_SESSION_ID_KEY = `${DSH_ENV_PREFIX}SESSION_ID` as const +const DSH_SESSION_JSONL_KEY = `${DSH_ENV_PREFIX}SESSION_JSONL` as const +const RESERVED_BASH_ENV_KEYS = new Set([ + DSH_HOME_ENV, + DSH_SHELL_KEY, + DSH_SESSION_ID_KEY, +]) +const BASH_ENV_KEY_SUFFIX = /^[A-Z][A-Z0-9_]*$/ + +/** + * Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. + * The namespace is rebuilt for every model bash call: ambient `DSH_*` values + * are discarded by the executor, then the registry's current snapshot is + * injected. Built-in shell facts remain owned by the registry itself while + * plugins can register additional, enumerable facts with effect-scoped + * disposal. + */ +export class BashEnvRegistry extends Service { + private readonly contributors = new Map() + private readonly keyOwners = new Map() + private readonly dshHome: string + + /** + * Create and install the `ctx.bashEnv` service. + * @param ctx - Cordis context that owns the service and registrations. + * @param config - home-directory configuration for the built-in variables. + */ + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'bashEnv') + this.dshHome = resolveDshHome(config.dshHome) + } + + /** + * Register one environment contributor. Names and keys are unique; built-in + * keys are reserved. Registration is disposed with the calling plugin fiber. + * @param contributor - declared key ownership and per-execution resolver. + * @returns the disposer that unregisters the contribution. + */ + register(contributor: BashEnvContributor): () => void { + const dispose = this.ctx.effect(function* (this: BashEnvRegistry) { + if (contributor.name.trim().length === 0) { + throw new Error('bash env contributor name must be non-empty') + } + if (this.contributors.has(contributor.name)) { + throw new Error(`bash env contributor "${contributor.name}" is already registered`) + } + + const variables = Object.entries(contributor.variables) as [DshEnvironmentKey, BashEnvVariable][] + for (const [key, variable] of variables) { + if (!key.startsWith(DSH_ENV_PREFIX) + || !BASH_ENV_KEY_SUFFIX.test(key.slice(DSH_ENV_PREFIX.length))) { + throw new Error(`bash env contributor "${contributor.name}" declared invalid key "${key}"`) + } + if (RESERVED_BASH_ENV_KEYS.has(key)) { + throw new Error(`bash env contributor "${contributor.name}" cannot own reserved key "${key}"`) + } + if (variable.description.trim().length === 0) { + throw new Error(`bash env contributor "${contributor.name}" must describe "${key}"`) + } + const owner = this.keyOwners.get(key) + if (owner !== undefined) { + throw new Error(`bash env key "${key}" is already owned by contributor "${owner}"; contributor "${contributor.name}" cannot also own it`) + } + } + + this.contributors.set(contributor.name, contributor) + for (const [key] of variables) this.keyOwners.set(key, contributor.name) + yield () => { + this.contributors.delete(contributor.name) + for (const [key] of variables) this.keyOwners.delete(key) + } + }.bind(this), 'bashEnv.register()') + return () => void dispose() + } + + /** + * Build the trusted `DSH_*` snapshot for one bash tool execution. + * @param execution - the current tool execution. + * @returns an immutable environment overlay containing built-ins and current contributions. + */ + collect(execution: ToolExecution): DshEnvironment { + const values: Record = { + [DSH_HOME_ENV]: this.dshHome, + [DSH_SHELL_KEY]: '1', + } + if (execution.agent !== undefined) { + values[DSH_SESSION_ID_KEY] = execution.agent.session.header.id + } + + for (const contributor of [...this.contributors.values()].sort((left, right) => left.name.localeCompare(right.name))) { + const resolved = contributor.resolve(execution) + for (const [rawKey, value] of Object.entries(resolved)) { + const key = rawKey as DshEnvironmentKey + if (!Object.hasOwn(contributor.variables, key)) { + throw new Error(`bash env contributor "${contributor.name}" returned undeclared key "${key}"`) + } + if (typeof value !== 'string') { + throw new Error(`bash env contributor "${contributor.name}" returned a non-string value for "${key}"`) + } + values[key] = value + } + } + + return Object.freeze(Object.fromEntries(Object.entries(values).sort(([left], [right]) => left.localeCompare(right)))) + } + + // TODO(bash-env-list-builtins): Include registry-owned built-ins before diagnostics, + // prompt, or UI code treats list() as an exhaustive environment catalog. + /** + * Enumerate plugin-contributed variables without executing their resolvers. + * @returns declarations sorted by environment variable name. + */ + list(): BashEnvVariableInfo[] { + return [...this.contributors.values()] + .flatMap(contributor => Object.entries(contributor.variables).map(([key, variable]) => ({ + contributor: contributor.name, + description: variable.description, + key: key as DshEnvironmentKey, + }))) + .sort((left, right) => left.key.localeCompare(right.key)) + } +} + /** Parsed tool args; execute validates value constraints absent from SchemaSpec. */ interface BashToolArgs { command: string @@ -82,6 +251,7 @@ function bashDescription(backgroundEnabled: boolean, escalationModes: readonly S const base = '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]`. ' + + `Current harness environment facts are exposed through managed \`$${DSH_ENV_PREFIX}*\` variables; inspect them when needed. ` + '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. ' + background @@ -153,7 +323,22 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent return modelWorkdir } -export function apply(ctx: Context, config: Config): void { +export function apply(ctx: Context, config: Config = {}): void { + const bashEnv = new BashEnvRegistry(ctx, config) + bashEnv.register({ + name: 'session-persistence', + variables: { + [DSH_SESSION_JSONL_KEY]: { + description: 'Absolute target path of the current session JSONL when the active persistence backend provides one.', + }, + }, + resolve(execution) { + const agent = execution.agent + if (agent === undefined) return {} + const location = ctx.get('sessionPersistence')?.locate(agent.session.header) + return location?.kind === 'jsonl' ? { [DSH_SESSION_JSONL_KEY]: location.path } : {} + }, + }) const backgroundEnabled = config.enableRunInBackground ?? true const defaultMode = ctx.bash.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS @@ -235,10 +420,12 @@ export function apply(ctx: Context, config: Config): void { ? await approveEscalation(args.sandbox_permissions, args.justification, exec) : sessionOverride(exec) const workdir = resolveWorkdir(args.workdir, exec) + const dshEnv = bashEnv.collect(exec) const request = { command: args.command, ...workdir !== undefined ? { workdir } : {}, ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, + dshEnv, ...sandboxMode !== undefined ? { sandboxMode } : {}, } if (args.run_in_background === true) { diff --git a/packages/bash/tool-bash/tests/bash-env.spec.ts b/packages/bash/tool-bash/tests/bash-env.spec.ts new file mode 100644 index 0000000000..03d29b572b --- /dev/null +++ b/packages/bash/tool-bash/tests/bash-env.spec.ts @@ -0,0 +1,190 @@ +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash' + +afterEach(() => vi.unstubAllEnvs()) + +function execution(sessionId?: string): ToolExecution { + return { + token: Symbol('bash-env-test') as ToolExecution['token'], + callId: CallId('bash-env-call'), + name: 'bash', + arguments: { command: 'true' }, + ...(sessionId === undefined + ? {} + : { agent: { session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as Agent }), + } +} + +describe('BashEnvRegistry', () => { + it('collects unconditional shell facts and the current agent session id', () => { + const ctx = new Context() + const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) + + expect(registry.collect(execution())).toEqual({ + DSH_HOME: resolve('./test-dsh-home'), + DSH_SHELL: '1', + }) + expect(registry.collect(execution('session-a'))).toEqual({ + DSH_HOME: resolve('./test-dsh-home'), + DSH_SESSION_ID: 'session-a', + DSH_SHELL: '1', + }) + }) + + it('resolves DSH_HOME from the ambient override or the user-home default', () => { + vi.stubEnv('DSH_HOME', './ambient-dsh-home') + const fromEnvironment = new BashEnvRegistry(new Context()) + expect(fromEnvironment.collect(execution()).DSH_HOME).toBe(resolve('./ambient-dsh-home')) + + vi.stubEnv('DSH_HOME', undefined) + const fromDefault = new BashEnvRegistry(new Context()) + expect(fromDefault.collect(execution()).DSH_HOME).toBe(join(homedir(), '.dsh')) + }) + + it('collects declared contributor variables and omits unavailable values', () => { + const ctx = new Context() + const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) + registry.register({ + name: 'optional-session-fact', + variables: { + DSH_SESSION_OPTIONAL: { description: 'Optional session-scoped test fact.' }, + }, + resolve: exec => exec.agent === undefined ? {} : { DSH_SESSION_OPTIONAL: exec.agent.session.header.id }, + }) + registry.register({ + name: 'always-available-fact', + variables: { + DSH_ALWAYS_AVAILABLE: { description: 'Always-available test fact.' }, + }, + resolve: () => ({ DSH_ALWAYS_AVAILABLE: 'yes' }), + }) + + expect(registry.collect(execution())).not.toHaveProperty('DSH_SESSION_OPTIONAL') + expect(registry.collect(execution()).DSH_ALWAYS_AVAILABLE).toBe('yes') + expect(registry.collect(execution('session-b')).DSH_SESSION_OPTIONAL).toBe('session-b') + expect(registry.list()).toEqual([ + { + contributor: 'always-available-fact', + description: 'Always-available test fact.', + key: 'DSH_ALWAYS_AVAILABLE', + }, + { + contributor: 'optional-session-fact', + description: 'Optional session-scoped test fact.', + key: 'DSH_SESSION_OPTIONAL', + }, + ]) + }) + + it('rejects duplicate variable ownership at registration time', () => { + const ctx = new Context() + const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) + registry.register({ + name: 'first', + variables: { DSH_SHARED: { description: 'First owner.' } }, + resolve: () => ({ DSH_SHARED: 'first' }), + }) + + expect(() => registry.register({ + name: 'second', + variables: { DSH_SHARED: { description: 'Second owner.' } }, + resolve: () => ({ DSH_SHARED: 'second' }), + })).toThrow(/DSH_SHARED.*first.*second|DSH_SHARED.*second.*first/) + }) + + it('rejects duplicate contributor names and malformed declarations', () => { + const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' }) + registry.register({ + name: 'declared', + variables: { DSH_DECLARED: { description: 'Declared fact.' } }, + resolve: () => ({}), + }) + + expect(() => registry.register({ + name: 'declared', + variables: { DSH_ANOTHER: { description: 'Another fact.' } }, + resolve: () => ({}), + })).toThrow(/already registered/) + expect(() => registry.register({ + name: ' ', + variables: { DSH_BLANK_NAME: { description: 'Blank owner.' } }, + resolve: () => ({}), + })).toThrow(/name must be non-empty/) + expect(() => registry.register({ + name: 'invalid-key', + variables: { dsh_invalid: { description: 'Invalid key.' } } as unknown as Record<'DSH_INVALID', { description: string }>, + resolve: () => ({}), + })).toThrow(/invalid key/) + expect(() => registry.register({ + name: 'reserved-key', + variables: { DSH_HOME: { description: 'Reserved key.' } }, + resolve: () => ({}), + })).toThrow(/reserved key/) + expect(() => registry.register({ + name: 'blank-description', + variables: { DSH_BLANK_DESCRIPTION: { description: ' ' } }, + resolve: () => ({}), + })).toThrow(/must describe/) + }) + + it('rejects undeclared variables returned by a contributor', () => { + const ctx = new Context() + const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) + registry.register({ + name: 'drifted-provider', + variables: { DSH_DECLARED: { description: 'Declared fact.' } }, + resolve: () => ({ DSH_UNDECLARED: 'bad' }), + }) + + expect(() => registry.collect(execution())).toThrow(/drifted-provider.*DSH_UNDECLARED/) + }) + + it('rejects non-string values returned by a contributor', () => { + const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' }) + registry.register({ + name: 'wrong-value-type', + variables: { DSH_STRING: { description: 'String fact.' } }, + resolve: () => ({ DSH_STRING: 42 }) as unknown as Record<'DSH_STRING', string>, + }) + + expect(() => registry.collect(execution())).toThrow(/wrong-value-type.*non-string.*DSH_STRING/) + }) + + it('removes an effect-scoped contributor when its plugin is disposed', async () => { + const ctx = new Context() + const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' }) + const fiber = await ctx.plugin({ + inject: ['bashEnv'], + apply(inner: Context) { + inner.bashEnv.register({ + name: 'temporary', + variables: { DSH_TEMPORARY: { description: 'Temporary fact.' } }, + resolve: () => ({ DSH_TEMPORARY: 'present' }), + }) + }, + }) + + expect(registry.collect(execution()).DSH_TEMPORARY).toBe('present') + await fiber.dispose() + expect(registry.collect(execution())).not.toHaveProperty('DSH_TEMPORARY') + }) + + it('returns an explicit contributor disposer', () => { + const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' }) + const dispose = registry.register({ + name: 'explicit-disposal', + variables: { DSH_EXPLICIT_DISPOSAL: { description: 'Explicitly disposed fact.' } }, + resolve: () => ({ DSH_EXPLICIT_DISPOSAL: 'present' }), + }) + + expect(registry.collect(execution()).DSH_EXPLICIT_DISPOSAL).toBe('present') + dispose() + expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL') + }) +}) diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 238176b8a6..dbeac75e28 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -1,12 +1,13 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' -import type { SessionEvent } 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 { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import TaskService from '@deepseek-ai/dsh-tasks' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' @@ -19,22 +20,25 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent * (tool/call + tool/result session events, the generic `ctx.tasks` runtime, * agent.inject completion notices). */ -async function harness(adapter: MockAdapter) { +async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: string) { const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) + if (sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TaskService) await ctx.plugin(ToolTasks) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - await ctx.plugin(ToolBash) + await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome }) ctx.llm.registerAdapter(['mock'], adapter) return ctx } +const dirs: string[] = [] +afterEach(() => { + vi.unstubAllEnvs() + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { @@ -82,6 +86,39 @@ async function pollUntil(predicate: () => boolean, timeoutMs = 5_000): Promise { + it('first-turn bash receives session identity before the lazy JSONL file materializes', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-bash-session-env-')) + dirs.push(root) + const dshHome = join(root, 'dsh-home') + vi.stubEnv('DSH_STALE_PARENT', 'stale') + const adapter = new MockAdapter([ + toolCallResponse('call-1', 'bash', { + command: 'printf \'%s\\n%s\\n%s\\n%s\\n%s\\n\' "$DSH_HOME" "$DSH_SHELL" "$DSH_SESSION_ID" "$DSH_SESSION_JSONL" "${DSH_STALE_PARENT-unset}"; if [ -e "$DSH_SESSION_JSONL" ]; then printf \'present\\n\'; else printf \'absent\\n\'; fi', + description: 'inspect session environment', + }), + textResponse('Session environment inspected.'), + ]) + const ctx = await harness(adapter, root, dshHome) + const handle = await ctx.agents.create({ + agentId: AgentId('session-env'), + sessionId: SessionId('session-env-id'), + agentOptions: { model: 'mock' }, + }) + const agent = handle.agent as ReactLoopAgent + const location = ctx.sessionPersistence.locate(agent.session.header) + expect(location?.kind).toBe('jsonl') + + agent.send([{ type: 'text', text: 'inspect the current session' }]) + await waitForIdle(ctx, agent) + + const result = findEvent(events(agent), 'tool/result') + expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`) + expect(existsSync(location!.path)).toBe(true) + const header = JSON.parse(readFileSync(location!.path, 'utf8').split('\n')[0]!) as { type: string; id: string } + expect(header).toMatchObject({ type: 'session', id: 'session-env-id' }) + await handle.dispose() + }) + it('foreground: model calls bash, sees the result, replies', async () => { const adapter = new MockAdapter([ toolCallResponse('call-1', 'bash', { command: 'echo integration-ok', description: 'test command' }, 'Running it.'), diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index c09c14c459..c19846ab13 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -10,6 +10,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import TaskService from '@deepseek-ai/dsh-tasks' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import ApprovalService from '@deepseek-ai/dsh-user-approval' @@ -101,6 +103,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 +143,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')) } @@ -924,14 +933,17 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { }) describe('the model-facing bash tool builds its request from named args only (no {...args} forward)', () => { + const recordingDshHome = join(spillDir, 'dsh-home') + /** * 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,9 +956,11 @@ 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 } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, sandboxMode: request.sandboxMode, } } @@ -968,19 +982,127 @@ describe('the model-facing bash tool builds its request from named args only (no } } - async function setupRecording() { + async function setupRecording(withJsonl = false) { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + if (withJsonl) { + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root: join(spillDir, 'jsonl') }) + } await ctx.plugin(TaskService) await ctx.plugin(ToolTasks) await ctx.plugin(RecordingBashExecutor) - await ctx.plugin(ToolBash) + await ctx.plugin(ToolBash, { dshHome: recordingDshHome }) return { ctx, bash: ctx.bash as RecordingBashExecutor } } - it('does not forward env/stdin even when the model includes them as extra arguments', async () => { + it('describes the managed harness environment namespace to the model', async () => { + const { ctx } = await setupRecording() + const description = ctx.tools.get('bash')?.description ?? '' + expect(description).toContain('$DSH_*') + expect(description).not.toContain('DSH_SESSION_JSONL') + }) + + it('injects the session id and JSONL target path into a foreground request', async () => { + const { ctx, bash } = await setupRecording(true) + const agent = registerFakeAgent(ctx, 'request-fg', () => undefined) + const path = ctx.sessionPersistence.locate(agent.session.header)?.path + + await ctx.tools.execute({ + callId: CallId('session-env-fg'), + name: 'bash', + arguments: { command: 'true', description: 'run command' }, + agent, + }) + + expect(bash.requests[0]?.dshEnv).toEqual({ + DSH_HOME: recordingDshHome, + DSH_SESSION_ID: 'request-fg', + DSH_SESSION_JSONL: path, + DSH_SHELL: '1', + }) + }) + + it('injects the same trusted variables into a background request without forwarding model env', async () => { + const { ctx, bash } = await setupRecording(true) + const agent = registerFakeAgent(ctx, 'request-bg', () => undefined) + const path = ctx.sessionPersistence.locate(agent.session.header)?.path + + await ctx.tools.execute({ + callId: CallId('session-env-bg'), + name: 'bash', + arguments: { + command: 'sleep 1', + description: 'run command', + run_in_background: true, + env: { DSH_SESSION_ID: 'spoofed', DSH_SESSION_JSONL: '/tmp/spoofed' }, + }, + agent, + }) + + expect(bash.requests[0]?.env).toBeUndefined() + expect(bash.requests[0]?.dshEnv).toEqual({ + DSH_HOME: recordingDshHome, + DSH_SESSION_ID: 'request-bg', + DSH_SESSION_JSONL: path, + DSH_SHELL: '1', + }) + }) + + it('injects built-ins and the stable session id when no JSONL locator is available', async () => { + const { ctx, bash } = await setupRecording() + const agent = registerFakeAgent(ctx, 'request-id-only', () => undefined) + const ambient = process.env.DSH_SESSION_ID + + await ctx.tools.execute({ + callId: CallId('session-env-id-only'), + name: 'bash', + arguments: { command: 'true', description: 'run command' }, + agent, + }) + + expect(bash.requests[0]?.dshEnv).toEqual({ + DSH_HOME: recordingDshHome, + DSH_SESSION_ID: 'request-id-only', + DSH_SHELL: '1', + }) + expect(process.env.DSH_SESSION_ID).toBe(ambient) + }) + + it('keeps parent and child agent session environments isolated', async () => { + const { ctx, bash } = await setupRecording(true) + const parent = registerFakeAgent(ctx, 'request-parent', () => undefined) + const child = registerFakeAgent(ctx, 'request-child', () => undefined) + + for (const [callId, agent] of [['parent', parent], ['child', child]] as const) { + await ctx.tools.execute({ + callId: CallId(`session-env-${callId}`), + name: 'bash', + arguments: { command: 'true', description: 'run command' }, + agent, + }) + } + + expect(bash.requests.map(request => request.dshEnv)).toEqual([ + { + DSH_HOME: recordingDshHome, + DSH_SESSION_ID: 'request-parent', + DSH_SESSION_JSONL: ctx.sessionPersistence.locate(parent.session.header)?.path, + DSH_SHELL: '1', + }, + { + DSH_HOME: recordingDshHome, + DSH_SESSION_ID: 'request-child', + DSH_SESSION_JSONL: ctx.sessionPersistence.locate(child.session.header)?.path, + DSH_SHELL: '1', + }, + ]) + expect(bash.requests[0]?.dshEnv?.DSH_SESSION_JSONL).not.toBe(bash.requests[1]?.dshEnv?.DSH_SESSION_JSONL) + }) + + 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 +1115,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 +1123,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 +1137,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 +1149,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/bash/tool-bash/tsconfig.json b/packages/bash/tool-bash/tsconfig.json index 6957c10fdf..407e78ebd8 100644 --- a/packages/bash/tool-bash/tsconfig.json +++ b/packages/bash/tool-bash/tsconfig.json @@ -26,9 +26,15 @@ { "path": "../../core/agent" }, + { + "path": "../../session-persistence/session-persistence" + }, { "path": "../../bash/bash" }, + { + "path": "../../util/home" + }, { "path": "../../tasks/tasks" }, diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index 32852a060f..fae0af5c62 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -31,11 +31,11 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index dbf8a3b737..f2d1a60ad8 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -1,14 +1,12 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' import { isToolPairingBalanced } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type { SurfaceEvent } from '@deepseek-ai/dsh-session' @@ -60,12 +58,8 @@ class StepwiseToolAdapter extends LlmAdapter { async function harness(toolSteps: number): Promise<{ ctx: Context; compact: ReproCompactService }> { const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(Invariants) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) ctx.tools.register(defineTool({ diff --git a/packages/context/README.md b/packages/context/README.md index 374fb96374..8a947703ec 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -1,10 +1,10 @@ # context/ — request-context extensions -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. +Product plugins that add 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) | +| `time-context/` | Durable per-step current time and elapsed-time 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/README.md b/packages/context/time-context/README.md index 84d487445b..6e114ea7e0 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-time-context -Opt-in dynamic system-prompt context with the current zoned time and elapsed time since the latest model-visible message before the turn. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the time-context RFC](../../../docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md). +Opt-in durable context with the current zoned time and elapsed time sampled during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the durable time-context RFC](../../../docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.md). ## Config @@ -8,36 +8,51 @@ Opt-in dynamic system-prompt context with the current zoned time and elapsed tim - id: time-context name: '@deepseek-ai/dsh-time-context' config: - timeZone: Asia/Shanghai # optional IANA override; omit for the process zone - refreshIntervalMs: 60000 # default; 0 refreshes on every step + timeZone: Asia/Shanghai # optional IANA override; omit for the process zone + refreshIntervalMs: 60000 # optional; omit or set to 0 for every eligible attempt ``` -When `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit `timeZone` must be an IANA identifier and is validated at plugin load. `refreshIntervalMs` must be a non-negative safe integer. Every turn's first request refreshes; later steps reuse the reading until its age reaches the interval. `0` refreshes every step. Refresh occurs only during request assembly and creates no timer work. +When `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit `timeZone` must be an IANA identifier and is validated at plugin load. -## Message baseline +`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` appends on every pre-step attempt whose signal is not already aborted. A positive value appends only when the session has no earlier time-context injection, wall time moved backward, or at least that many milliseconds have elapsed since the latest injection. -The duration starts at the latest user, assistant, tool-result, context, or steering message before the current `turn/start`. Every refresh in the turn retains that baseline, so the current prompt does not collapse the interval to approximately zero. The first turn reports that no earlier message exists. The durable clock source is session-event append time, not client send time. +## Timing semantics -The loop records the dynamic section in `request/header` / `request/header-delta`. Requests therefore remain reconstructable, carry one timing block, and retain no earlier readings in conversation history. +The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one `context/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing. + +Positive-interval scheduling scans the raw durable session events for the latest `context/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently. + +Step 1 measures from the latest preceding model-visible message, including the prompt that opened the turn. Later steps measure from the preceding time-context event in the same turn. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline, or a later step with no earlier same-turn reading because interval suppression skipped it, reports `unavailable`. + +A time reading records a request-preparation attempt, not a committed step or transmitted request. Because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt; the log is append-only and the plugin performs no rollback. + +The time reading stays in derived conversation history until a later compaction shadows it. Request headers and header deltas contain no time-context state. Request reconstruction uses the complete durable surface prefix at each `step/start`, so transmitted requests need not map one-to-one to readings: a failed preparation can leave an extra reading, while interval suppression can let a request reuse existing history without adding one. ## Model Experience -### Temporal system prompt +### Preparation-time temporal context -**What the model sees**: Every request in an active turn includes the two lines below. `` is an ISO-shaped local timestamp with numeric offset and IANA zone; `` is compact whole-second units or the first-turn fallback. +**What the model sees**: On each preparation attempt that injects, one source-tagged context message containing the two lines below. `` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. Positive intervals can leave an attempted step without a new reading. -**Token effect**: Fixed two-line cost per request. A refresh replaces the request-header section; prior readings do not accumulate. +**Token effect**: Each injected two-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every eligible preparation attempt. -#### Temporal context section +#### First step ```markdown -Current time: -Time since previous message: . +Time sampled while preparing turn , step 1: +Elapsed since the preceding model-visible message: . +``` + +#### Later steps + +```markdown +Time sampled while preparing turn , step : +Elapsed since the preceding step context: . ``` ## Known Limitations and Deferred Work -- **Request-bound refresh only** — no clock update is emitted while the agent is waiting inside a model call or tool; the next assembled step refreshes once the configured interval has elapsed. -- **Whole-second display** — timestamps and durations omit sub-second precision even when `refreshIntervalMs` is below 1,000. -- **Session-event baseline** — elapsed time starts from the durable append timestamp, not a client transport's original send timestamp. +- **Whole-second display** — timestamps and durations omit sub-second precision even though durable event times retain milliseconds. +- **Session-event baseline** — elapsed time starts from durable append timestamps, not a client transport's original send timestamp. - **Process-local default zone** — omission uses the Node process's `TZ`, host, or container zone captured at plugin load, not a remote user's zone; configure an explicit IANA zone when those differ. +- **History cost between compactions** — omission or `0` retains one reading for every eligible preparation attempt, including attempts later cancelled or failed; a positive interval reduces but does not eliminate this cost. diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index f319c7a5b1..8900eaf900 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-time-context", - "description": "Opt-in system-prompt context with the current time and elapsed time since the previous message", + "description": "Opt-in durable per-step context with the current time and elapsed time", "version": "0.0.1", "private": true, "type": "module", @@ -26,12 +26,12 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index cccd433811..f8463e4bec 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -1,8 +1,6 @@ /** - * Opt-in request-time clock context. Active turns receive the current zoned - * time and elapsed time since the preceding model-visible message. The loop - * logs each rendered value as request-header state rather than conversation - * history. + * Opt-in request-preparation clock context. Eligible pre-step attempts append + * durable, source-attributed time readings to conversation history. * * @module @deepseek-ai/dsh-time-context */ @@ -10,77 +8,30 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt' +import type { Message } from '@deepseek-ai/dsh-llm' /** Cordis plugin name used by loader diagnostics. */ export const name = 'time-context' -/** The system-prompt registry that owns the dynamic request section. */ -export const inject = ['systemPrompt'] +/** The agent registry that owns the pre-step lifecycle seam. */ +export const inject = ['agents'] -/** Request-time clock formatting and refresh policy. Invalid values fail plugin load. */ +/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ export interface Config { /** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */ timeZone?: string - /** Maximum age of a reading within one turn, in milliseconds (default 60,000; `0` refreshes every step). */ + /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible pre-step attempt. */ refreshIntervalMs?: number } -/** Schemastery validation and defaults for {@link Config}. */ +/** Schemastery validation for {@link Config}. */ export const Config: z = z.object({ timeZone: z.string(), - refreshIntervalMs: z.number().default(60_000), + refreshIntervalMs: z.number(), }) -interface OpenTurn { - turn: number - startSeq: number -} - -/** Cached text and the fixed inter-turn baseline used by one agent's open turn. */ -interface RenderState { - turn: number - renderedAt: number - previousMessageTime: number | undefined - text: string -} - type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year' -function openTurn(agent: Agent): OpenTurn | undefined { - for (const event of [...agent.session.events].reverse()) { - switch (event.type) { - case 'turn/end': - return undefined - case 'turn/start': - return { turn: event.data.turn, startSeq: event.seq } - default: - // Merge-extensible session events: only turn boundaries matter here. - break - } - } - return undefined -} - -/** Find the latest model-visible timestamp strictly before one turn boundary. */ -function previousMessageTime(agent: Agent, turnStartSeq: number): number | undefined { - for (const event of [...agent.session.events].reverse()) { - if (event.seq >= turnStartSeq) continue - switch (event.type) { - case 'user/message': - case 'assistant/message': - case 'tool/result': - case 'context/message': - case 'steering/message': - return event.time - default: - // Merge-extensible session events: non-surface records are not messages. - break - } - } - return undefined -} - /** Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone. */ function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string { const parts = Object.fromEntries( @@ -107,31 +58,85 @@ function formatDuration(elapsedMs: number): string { return parts.join(' ') } +/** Find the latest model-visible event, excluding this plugin's pending append. */ +function precedingMessageTime(agent: Agent): number | undefined { + for (const event of [...agent.session.events].reverse()) { + switch (event.type) { + case 'user/message': + case 'assistant/message': + case 'tool/result': + case 'context/message': + case 'steering/message': + return event.time + default: + // Merge-extensible session events: non-surface records are not messages. + break + } + } + return undefined +} + +/** Find the preceding time-context event within the open turn. */ +function precedingStepContextTime(agent: Agent, turn: number): number | undefined { + for (const event of [...agent.session.events].reverse()) { + if (event.type === 'turn/start' && event.data.turn === turn) return undefined + if (event.type === 'context/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === name) { + return event.time + } + } + return undefined +} + +/** Find this plugin's latest durable injection, including a shadowed surface event. */ +function latestInjectionTime(agent: Agent): number | undefined { + for (const event of [...agent.session.events].reverse()) { + if (event.type === 'context/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === name) { + return event.time + } + } + return undefined +} + function renderText( now: number, + turn: number, + step: number, previous: number | undefined, formatter: Intl.DateTimeFormat, timeZone: string, ): string { - const elapsed = previous === undefined - ? 'unavailable (no earlier message in this session)' - : formatDuration(now - previous) - return `Current time: ${formatTimestamp(now, formatter, timeZone)}\nTime since previous message: ${elapsed}.` + const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous) + const baseline = step === 1 ? 'model-visible message' : 'step context' + return `Time sampled while preparing turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n` + + `Elapsed since the preceding ${baseline}: ${elapsed}.` +} + +/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */ +function validateRefreshInterval(refreshIntervalMs: number | undefined): void { + if (refreshIntervalMs !== undefined && ( + !Number.isSafeInteger(refreshIntervalMs) + || refreshIntervalMs < 0 + )) { + throw new TypeError( + `time-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`, + ) + } } /** - * Register the request-time clock section for the lifetime of `ctx`. - * @param ctx - plugin context; the section registration is disposed with it. - * @param config - validated time zone and intra-turn refresh interval. - * @throws when the time zone or refresh interval is invalid. + * Register a prepended pre-step listener for the lifetime of `ctx`. + * @param ctx - plugin context; the listener is disposed with it. + * @param config - time zone and durable refresh scheduling configuration. + * @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved. */ export function apply(ctx: Context, config: Config): void { const timeZone = config.timeZone - const refreshIntervalMs = config.refreshIntervalMs as number - if (!Number.isSafeInteger(refreshIntervalMs) || refreshIntervalMs < 0) { - throw new Error(`time-context: refreshIntervalMs must be a non-negative safe integer, got ${refreshIntervalMs}`) - } - + const refreshIntervalMs = config.refreshIntervalMs + validateRefreshInterval(refreshIntervalMs) let formatter: Intl.DateTimeFormat try { formatter = new Intl.DateTimeFormat('en-US', { @@ -152,32 +157,29 @@ export function apply(ctx: Context, config: Config): void { throw new Error(message, { cause: error }) } const resolvedTimeZone = formatter.resolvedOptions().timeZone - const states = new WeakMap() - ctx.systemPrompt.section({ - name: 'context:time', - order: 10, - text(context: AssembleContext): string { - const agent = context.agent - if (agent === undefined) return '' - const currentTurn = openTurn(agent) - if (currentTurn === undefined) return '' - - const now = Date.now() - const prior = states.get(agent) - if (prior !== undefined - && prior.turn === currentTurn.turn - && now >= prior.renderedAt - && now - prior.renderedAt < refreshIntervalMs) { - return prior.text - } - - const previous = prior?.turn === currentTurn.turn - ? prior.previousMessageTime - : previousMessageTime(agent, currentTurn.startSeq) - const text = renderText(now, previous, formatter, resolvedTimeZone) - states.set(agent, { turn: currentTurn.turn, renderedAt: now, previousMessageTime: previous, text }) - return text - }, - }) + ctx.on('agent/pre-step', ( + agent: Agent, + turn: number, + step: number, + _fullSystemPrompt: string, + _sessionPrefix: readonly Message[], + signal: AbortSignal, + ) => { + if (signal.aborted) return + const now = Date.now() + if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) { + const lastInjection = latestInjectionTime(agent) + if (lastInjection !== undefined + && now >= lastInjection + && now - lastInjection < refreshIntervalMs) return + } + const previous = step === 1 + ? precedingMessageTime(agent) + : precedingStepContextTime(agent, turn) + agent.inject( + [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }], + { source: { kind: 'plugin', plugin: name } }, + ) + }, { prepend: true }) } diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts index f83b451ef7..4d5b2ed122 100644 --- a/packages/context/time-context/tests/time-context.e2e.ts +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { foldRequestHeader, type SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('./fixtures/cordis.yml', import.meta.url)) @@ -12,7 +12,8 @@ const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.m const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const PROCESS_TIMEOUT_MS = 30_000 const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 -const FIRST_REPLY = 'You said: "first". Try "echo " to see a tool call.' +const FIRST_REPLY = '[main turn 1] You said: "Time sampled while preparing turn 1, step 1:' +const SECOND_REPLY = '[main turn 2] You said: "Time sampled while preparing turn 2, step 1:' let child: ChildProcessWithoutNullStreams | undefined let workdir: string | undefined @@ -60,7 +61,7 @@ async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> { proc.stdout.setEncoding('utf8') proc.stdout.on('data', (chunk: string) => { stdout += chunk - if (!sentSecond && stdout.includes(`${FIRST_REPLY}\n> `)) { + if (!sentSecond && stdout.includes(FIRST_REPLY) && stdout.includes('Try "echo " to see a tool call.\n> ')) { sentSecond = true proc.stdin.end('second\n') } @@ -84,12 +85,12 @@ async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> { } describe('time-context through a real cordis.yml and stdio process', () => { - it('uses the process zone and persists both first-turn and elapsed-time request context', async () => { + it('uses the process zone and persists one ordered context event per request', async () => { const { stdout, stderr } = await runTwoTurns() expect(stderr).not.toContain('UNHANDLED') expect(stdout).toContain('time-context e2e ready.') expect(stdout).toContain(FIRST_REPLY) - expect(stdout).toContain('You said: "second".') + expect(stdout).toContain(SECOND_REPLY) const logs = await jsonlFiles(join(workdir as string, '.sessions')) expect(logs).toHaveLength(1) @@ -97,19 +98,29 @@ describe('time-context through a real cordis.yml and stdio process', () => { const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) - const firstHeader = events.find(event => event.type === 'request/header') - if (firstHeader?.type !== 'request/header') throw new Error('missing initial request/header event') - expect(firstHeader.data.header.system).toMatch( - /Current time: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/, + const contexts = events.filter(event => event.type === 'context/message') + const starts = events.filter(event => event.type === 'step/start') + expect(contexts).toHaveLength(2) + expect(starts).toHaveLength(2) + for (let index = 0; index < contexts.length; index += 1) { + expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq) + expect(contexts[index]!.surfaceOp).toBe('append') + expect(contexts[index]!.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' }) + } + const contextText = contexts.map(event => event.data.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n')) + expect(contextText[0]).toMatch( + /Time sampled while preparing turn 1, step 1: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/, ) - expect(firstHeader.data.header.system).toContain( - 'Time since previous message: unavailable (no earlier message in this session).', + expect(contextText[0]).toMatch( + /Elapsed since the preceding model-visible message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./, ) + expect(contextText[1]).toMatch(/Time sampled while preparing turn 2, step 1:/) - const finalSystem = foldRequestHeader(events)?.system - expect(finalSystem).toContain('[Asia/Shanghai]') - expect(finalSystem).toMatch( - /Time since previous message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./, - ) + const headers = events.filter(event => event.type === 'request/header' + || event.type === 'request/header-delta') + expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing') }, TEST_TIMEOUT_MS) }) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 562b002b68..f5f8616f7f 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -1,19 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import { defineTool } from '@deepseek-ai/dsh-tools' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as timeContext from '@deepseek-ai/dsh-time-context' import type { Config } from '@deepseek-ai/dsh-time-context' const BASE = Date.parse('2026-07-14T00:00:00.000Z') const ORIGINAL_TIME_ZONE = process.env['TZ'] +const SIGNAL = new AbortController().signal beforeEach(() => { process.env['TZ'] = 'UTC' @@ -30,18 +31,29 @@ afterEach(() => { async function mount(config: Config = {}) { const ctx = new Context() - await ctx.plugin(SystemPrompt) + await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(timeContext, config) return { ctx, fiber } } function sessionAgent(session: Session, id = 'agent'): Agent { - return { id: AgentId(id), session } as unknown as Agent -} - -async function sectionText(ctx: Context, agent?: Agent): Promise { - const assembly = await ctx.systemPrompt.assemble(agent === undefined ? {} : { agent }) - return assembly.sections.find(section => section.name === 'context:time')?.text + return { + id: AgentId(id), + options: {}, + session, + status: 'running', + ctx: new Context(), + send() {}, + steer() {}, + inject(content, options) { + session.append('context/message', { + content, + source: options?.source ?? { kind: 'user' }, + }, { surfaceOp: 'append' }) + }, + cancel() {}, + whenIdle: () => Promise.resolve(), + } } function openMessageTurn(session: Session, turn: number): void { @@ -52,6 +64,28 @@ function openMessageTurn(session: Session, turn: number): void { }, { surfaceOp: 'append' }) } +function contextTexts(session: Session): string[] { + const texts: string[] = [] + for (const event of session.events) { + if (event.type === 'context/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'time-context') { + texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '') + } + } + return texts +} + +async function fire( + ctx: Context, + agent: Agent, + turn: number, + step: number, + signal: AbortSignal = SIGNAL, +): Promise { + await ctx.serial('agent/pre-step', agent, turn, step, '', [], signal) +} + function textResponse(text: string): StreamChunk[] { return [ { type: 'block-start', index: 0, blockType: 'text' }, @@ -89,184 +123,186 @@ class ScriptedAdapter extends LlmAdapter { async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promise { const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(timeContext, config) ctx.llm.registerAdapter(['mock'], adapter) return ctx } -describe('temporal section rendering', () => { - it('renders the first turn in UTC with the explicit no-previous-message fallback', async () => { - const { ctx } = await mount() +function requestText(request: GenerateOptions): string { + return request.messages + .flatMap(message => message.content) + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') +} + +describe('durable step context', () => { + it('records turn, step, zoned time, and the preceding model-visible message baseline', async () => { + const { ctx } = await mount({ timeZone: 'Asia/Shanghai' }) const session = new Session(SessionId('first')) openMessageTurn(session, 1) - - expect(await sectionText(ctx, sessionAgent(session))).toBe( - 'Current time: 2026-07-14T00:00:00+00:00[UTC]\n' - + 'Time since previous message: unavailable (no earlier message in this session).', - ) - }) - - it('renders a non-UTC numeric offset and all compact duration units', async () => { - const { ctx } = await mount({ timeZone: 'Asia/Shanghai' }) - const session = new Session(SessionId('offset')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('assistant/message', { - turn: 1, - step: 1, - content: [{ type: 'text', text: 'previous' }], - }, { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) vi.setSystemTime(BASE + 90_061_000) - openMessageTurn(session, 2) - expect(await sectionText(ctx, sessionAgent(session))).toBe( - 'Current time: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n' - + 'Time since previous message: 1d 1h 1m 1s.', + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toEqual([ + 'Time sampled while preparing turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n' + + 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.', + ]) + const event = session.events.at(-1) + expect(event?.type).toBe('context/message') + if (event?.type !== 'context/message') throw new Error('missing time context') + expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' }) + expect(event.surfaceOp).toBe('append') + }) + + it('reports an unavailable first-step baseline when no model-visible message precedes it', async () => { + const { ctx } = await mount() + const session = new Session(SessionId('unavailable')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)[0]).toContain( + 'Elapsed since the preceding model-visible message: unavailable.', ) }) - it('clamps a backward wall-clock adjustment to a zero duration', async () => { + it.each([ + ['omitted interval', {}], + ['zero interval', { refreshIntervalMs: 0 }], + ] as const)('uses the preceding durable step-context timestamp after step one with %s', async (_label, config) => { + const { ctx } = await mount(config) + const session = new Session(SessionId('later-step')) + const agent = sessionAgent(session) + openMessageTurn(session, 3) + await fire(ctx, agent, 3, 1) + vi.setSystemTime(BASE + 61_000) + + await fire(ctx, agent, 3, 2) + + expect(contextTexts(session)[1]).toBe( + 'Time sampled while preparing turn 3, step 2: 2026-07-14T00:01:01+00:00[UTC]\n' + + 'Elapsed since the preceding step context: 1m 1s.', + ) + }) + + it('reports an unavailable later-step baseline at the matching turn boundary', async () => { const { ctx } = await mount() - const session = new Session(SessionId('backward-duration')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('assistant/message', { - turn: 1, - step: 1, - content: [{ type: 'text', text: 'future by adjusted clock' }], - }, { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const session = new Session(SessionId('later-step-boundary')) + openMessageTurn(session, 4) + + await fire(ctx, sessionAgent(session), 4, 2) + + expect(contextTexts(session)[0]).toContain( + 'Elapsed since the preceding step context: unavailable.', + ) + }) + + it('reports an unavailable later-step baseline when event lookup is exhausted', async () => { + const { ctx } = await mount() + const session = new Session(SessionId('later-step-exhausted')) + + await fire(ctx, sessionAgent(session), 1, 2) + + expect(contextTexts(session)[0]).toContain( + 'Elapsed since the preceding step context: unavailable.', + ) + }) + + it('injects after backward wall-clock movement and clamps elapsed time to zero', async () => { + const { ctx } = await mount({ refreshIntervalMs: 60_000 }) + const session = new Session(SessionId('backward')) + const agent = sessionAgent(session) + openMessageTurn(session, 1) + await fire(ctx, agent, 1, 1) vi.setSystemTime(BASE - 5_000) - openMessageTurn(session, 2) - expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 0s.') + await fire(ctx, agent, 1, 2) + + expect(contextTexts(session)).toHaveLength(2) + expect(contextTexts(session)[1]).toContain('Elapsed since the preceding step context: 0s.') }) - const previousMessageCases = [ - ['user/message', (session: Session): void => { - session.append('user/message', { content: [{ type: 'text', text: 'u' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - }], - ['assistant/message', (session: Session): void => { - session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' }) - }], - ['tool/result', (session: Session): void => { - session.append('tool/result', { - turn: 1, - step: 1, - callId: CallId('previous'), - content: [{ type: 'text', text: 'r' }], - isError: false, - }, { surfaceOp: 'append' }) - }], - ['context/message', (session: Session): void => { - session.append('context/message', { - content: [{ type: 'text', text: 'c' }], - source: { kind: 'plugin', plugin: 'test' }, - }, { surfaceOp: 'append' }) - }], - ['steering/message', (session: Session): void => { - session.append('steering/message', { - turn: 1, - content: [{ type: 'text', text: 's' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - }], - ] as const + it('uses a shadowed durable injection after resume and injects at the exact threshold', async () => { + const { ctx } = await mount({ refreshIntervalMs: 1_000 }) + const original = new Session(SessionId('seed-source')) + openMessageTurn(original, 1) + await fire(ctx, sessionAgent(original), 1, 1) + const user = original.events.find(event => event.type === 'user/message') + const reading = original.events.find(event => event.type === 'context/message') + if (user === undefined || reading === undefined) throw new Error('missing source surface events') + original.append('context/message', { + content: [{ type: 'text', text: 'compacted history' }], + source: { kind: 'plugin', plugin: 'compact-basic' }, + }, { + surfaceOp: { op: 'replace', start: user.seq, end: reading.seq }, + sourceEventSeqs: [user.seq, reading.seq], + }) + original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing') - it.each(previousMessageCases)('uses a prior %s as the duration baseline', async (_name, appendPrevious) => { - const { ctx } = await mount() - const session = new Session(SessionId(`previous-${_name}`)) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - appendPrevious(session) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - vi.setSystemTime(BASE + 5_000) - openMessageTurn(session, 2) + const resumed = new Session(SessionId('resumed'), [...original.events]) + const resumedAgent = sessionAgent(resumed) + vi.setSystemTime(BASE + 999) + openMessageTurn(resumed, 2) + const beforeSkip = resumed.events.length - expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 5s.') - }) + await fire(ctx, resumedAgent, 2, 1) - it('contributes empty text without an active agent turn', async () => { - const { ctx } = await mount() - expect(await sectionText(ctx)).toBe('') + expect(resumed.events).toHaveLength(beforeSkip) + expect(contextTexts(resumed)).toHaveLength(1) - const empty = sessionAgent(new Session(SessionId('empty'))) - expect(await sectionText(ctx, empty)).toBe('') - - const closedSession = new Session(SessionId('closed')) - openMessageTurn(closedSession, 1) - closedSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect(await sectionText(ctx, sessionAgent(closedSession))).toBe('') - }) -}) - -describe('refresh policy', () => { - it('reuses within the interval, refreshes at expiry, and refreshes after a backward clock jump', async () => { - const { ctx } = await mount({ refreshIntervalMs: 60_000 }) - const session = new Session(SessionId('interval')) - const agent = sessionAgent(session) - openMessageTurn(session, 1) - - const first = await sectionText(ctx, agent) - vi.setSystemTime(BASE + 30_000) - expect(await sectionText(ctx, agent)).toBe(first) - vi.setSystemTime(BASE + 60_000) - const expired = await sectionText(ctx, agent) - expect(expired).toContain('2026-07-14T00:01:00+00:00[UTC]') - vi.setSystemTime(BASE + 59_000) - expect(await sectionText(ctx, agent)).toContain('2026-07-14T00:00:59+00:00[UTC]') - }) - - it('refreshes every assembly when refreshIntervalMs is zero', async () => { - const { ctx } = await mount({ refreshIntervalMs: 0 }) - const session = new Session(SessionId('every-step')) - const agent = sessionAgent(session) - openMessageTurn(session, 1) - const first = await sectionText(ctx, agent) vi.setSystemTime(BASE + 1_000) - expect(await sectionText(ctx, agent)).not.toBe(first) + await fire(ctx, resumedAgent, 2, 2) + + expect(contextTexts(resumed)).toHaveLength(2) + expect(contextTexts(resumed)[1]).toContain( + 'Elapsed since the preceding step context: unavailable.', + ) }) - it('always refreshes for a new turn and keeps the preceding message baseline', async () => { - const { ctx } = await mount({ refreshIntervalMs: 60_000 }) - const session = new Session(SessionId('turn-refresh')) + it('applies a positive interval across turns without sharing state between sessions', async () => { + const { ctx } = await mount({ refreshIntervalMs: 1_000 }) + const first = new Session(SessionId('interval-first')) + const firstAgent = sessionAgent(first, 'first-agent') + openMessageTurn(first, 1) + await fire(ctx, firstAgent, 1, 1) + first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + vi.setSystemTime(BASE + 500) + openMessageTurn(first, 2) + const beforeSkip = first.events.length + await fire(ctx, firstAgent, 2, 1) + + const independent = new Session(SessionId('interval-independent')) + openMessageTurn(independent, 1) + await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1) + + expect(first.events).toHaveLength(beforeSkip) + expect(contextTexts(first)).toHaveLength(1) + expect(contextTexts(independent)).toHaveLength(1) + }) + + it('runs before ordinary pre-step listeners and skips an already-aborted step', async () => { + const { ctx } = await mount() + const session = new Session(SessionId('ordering')) const agent = sessionAgent(session) openMessageTurn(session, 1) - const first = await sectionText(ctx, agent) - vi.setSystemTime(BASE + 1_000) - session.append('assistant/message', { - turn: 1, - step: 1, - content: [{ type: 'text', text: 'done' }], - }, { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - vi.setSystemTime(BASE + 2_000) - openMessageTurn(session, 2) + let ordinarySawContext = false + ctx.on('agent/pre-step', (subject) => { + ordinarySawContext = subject.session.events.some(event => event.type === 'context/message') + }) - const second = await sectionText(ctx, agent) - expect(second).not.toBe(first) - expect(second).toContain('Time since previous message: 1s.') - }) + await fire(ctx, agent, 1, 1) + const abort = new AbortController() + abort.abort() + await fire(ctx, agent, 1, 2, abort.signal) - it('keeps refresh caches independent per agent', async () => { - const { ctx } = await mount({ refreshIntervalMs: 60_000 }) - const sessionA = new Session(SessionId('agent-a')) - const sessionB = new Session(SessionId('agent-b')) - const agentA = sessionAgent(sessionA, 'a') - const agentB = sessionAgent(sessionB, 'b') - openMessageTurn(sessionA, 1) - openMessageTurn(sessionB, 1) - const aFirst = await sectionText(ctx, agentA) - vi.setSystemTime(BASE + 30_000) - const bFirst = await sectionText(ctx, agentB) - vi.setSystemTime(BASE + 40_000) - - expect(await sectionText(ctx, agentA)).toBe(aFirst) - expect(bFirst).toContain('2026-07-14T00:00:30+00:00[UTC]') + expect(ordinarySawContext).toBe(true) + expect(contextTexts(session)).toHaveLength(1) }) }) @@ -278,49 +314,79 @@ describe('configuration and lifecycle', () => { const session = new Session(SessionId('system-zone')) openMessageTurn(session, 1) - expect(await sectionText(ctx, sessionAgent(session))).toContain( - 'Current time: 2026-07-14T08:00:00+08:00[Asia/Shanghai]', + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)[0]).toContain('2026-07-14T08:00:00+08:00[Asia/Shanghai]') + }) + + it('fails loud for an invalid explicit zone or an unavailable process zone', async () => { + const invalid = new Context() + await invalid.plugin(AgentRegistry) + await expect(invalid.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow( + /invalid IANA timeZone/, ) - }) - it('fails loud for negative, fractional, unsafe, and invalid-zone config', async () => { - for (const refreshIntervalMs of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await expect(ctx.plugin(timeContext, { refreshIntervalMs })).rejects.toThrow(/non-negative safe integer/) - } - - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await expect(ctx.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow(/invalid IANA timeZone/) - }) - - it('fails loud when the process system zone cannot be resolved', async () => { vi.spyOn(Intl, 'DateTimeFormat').mockImplementationOnce(() => { throw new RangeError('system zone unavailable') }) - const ctx = new Context() - await ctx.plugin(SystemPrompt) - - await expect(ctx.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/) + const unresolved = new Context() + await unresolved.plugin(AgentRegistry) + await expect(unresolved.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/) }) - it('removes its section when the plugin fiber disposes', async () => { + it('rejects invalid refresh intervals at plugin load with one diagnostic', async () => { + const invalid = [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, Number.POSITIVE_INFINITY, Number.NaN] + for (const refreshIntervalMs of invalid) { + await expect(mount({ refreshIntervalMs })).rejects.toThrow( + 'time-context: refreshIntervalMs must be a non-negative safe integer', + ) + } + }) + + it('removes its listener when the plugin fiber disposes', async () => { const { ctx, fiber } = await mount() const session = new Session(SessionId('dispose')) const agent = sessionAgent(session) openMessageTurn(session, 1) - expect(await sectionText(ctx, agent)).toContain('Current time:') + await fire(ctx, agent, 1, 1) await fiber.dispose() - expect(await sectionText(ctx, agent)).toBeUndefined() + await fire(ctx, agent, 1, 2) + + expect(contextTexts(session)).toHaveLength(1) }) }) -describe('real agent-loop request logging', () => { - it('refreshes a long turn in the system prompt and records the header delta without context history', async () => { - const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done'), textResponse('next turn')]) - const ctx = await loopHarness(adapter, { refreshIntervalMs: 60_000 }) +describe('real agent-loop request history', () => { + it.each([ + ['throws', 'error'], + ['cancels', 'aborted'], + ] as const)('retains the preparation reading when a later pre-step listener %s', async (mode, reasonKind) => { + const adapter = new ScriptedAdapter([textResponse('unused')]) + const ctx = await loopHarness(adapter) + let laterSawReading = false + ctx.on('agent/pre-step', (subject) => { + laterSawReading = contextTexts(subject.session).length === 1 + if (mode === 'throws') throw new Error('later pre-step failure') + subject.cancel('later pre-step cancellation') + }) + const agent = ctx.agentLoop.create(AgentId(`late-${mode}`), { model: 'mock' }) + + agent.send([{ type: 'text', text: 'start' }]) + await agent.whenIdle() + + expect(laterSawReading).toBe(true) + expect(contextTexts(agent.session)).toHaveLength(1) + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false) + const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe(reasonKind) + await ctx.fiber.dispose() + }) + + it('persists one ordered context per request, accumulates readings, and leaves system headers unchanged', async () => { + const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done')]) + const ctx = await loopHarness(adapter) ctx.tools.register(defineTool({ name: 'tick', description: 'advance fake time', @@ -334,38 +400,55 @@ describe('real agent-loop request logging', () => { agent.send([{ type: 'text', text: 'start' }]) await agent.whenIdle() - expect(adapter.requests).toHaveLength(2) - expect(adapter.requests[0]!.system).toContain('2026-07-14T00:00:00+00:00[UTC]') - expect(adapter.requests[1]!.system).toContain('2026-07-14T00:01:01+00:00[UTC]') - expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false) - expect(agent.session.events.filter(event => event.type === 'request/header-delta')).toHaveLength(1) - expect(foldRequestHeader(agent.session.events)?.system).toBe(adapter.requests[1]!.system) - vi.setSystemTime(BASE + 361_000) - agent.send([{ type: 'text', text: 'again' }]) - await agent.whenIdle() - expect(adapter.requests[2]!.system).toContain('Time since previous message: 5m 0s.') + expect(adapter.requests).toHaveLength(2) + const contexts = agent.session.events.filter(event => event.type === 'context/message') + const starts = agent.session.events.filter(event => event.type === 'step/start') + expect(contexts).toHaveLength(adapter.requests.length) + expect(starts).toHaveLength(adapter.requests.length) + for (let index = 0; index < contexts.length; index += 1) { + expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq) + } + expect(contexts.every(event => event.data.source.kind === 'plugin' + && event.data.source.plugin === 'time-context' + && event.surfaceOp === 'append')).toBe(true) + + const firstRequestText = requestText(adapter.requests[0]!) + const secondRequestText = requestText(adapter.requests[1]!) + expect(firstRequestText).toContain('Time sampled while preparing turn 1, step 1:') + expect(firstRequestText).toContain('Elapsed since the preceding model-visible message: 0s.') + expect(firstRequestText).not.toContain('Time sampled while preparing turn 1, step 2:') + expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 1:') + expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 2:') + expect(secondRequestText).toContain('Elapsed since the preceding step context: 1m 1s.') + + for (const request of adapter.requests) expect(request.system).not.toContain('Time sampled while preparing') + const headers = agent.session.events.filter(event => event.type === 'request/header' + || event.type === 'request/header-delta') + expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing') + expect(agent.session.events.filter(event => event.type === 'request/header-delta')).toHaveLength(0) await ctx.fiber.dispose() }) }) describe('real Loader export path', () => { - it('keeps the namespace metadata and boots through unwrapExports', async () => { + it('keeps namespace metadata and boots the agent listener through unwrapExports', async () => { expect('default' in timeContext).toBe(false) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(timeContext) as Record expect(unwrapped).toBe(timeContext) expect(unwrapped.name).toBe('time-context') - expect(unwrapped.inject).toEqual(['systemPrompt']) + expect(unwrapped.inject).toEqual(['agents']) expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') const ctx = new Context() - await ctx.plugin(SystemPrompt) + await ctx.plugin(AgentRegistry) const plugin = loader.unwrapExports(timeContext) as Parameters[0] await ctx.plugin(plugin) const session = new Session(SessionId('loader')) openMessageTurn(session, 1) - expect(await sectionText(ctx, sessionAgent(session))).toContain('Current time:') + await fire(ctx, sessionAgent(session), 1, 1) + expect(contextTexts(session)[0]).toContain('Time sampled while preparing turn 1, step 1:') }) }) diff --git a/packages/context/time-context/tsconfig.json b/packages/context/time-context/tsconfig.json index eda3a81772..f8e14d8aa2 100644 --- a/packages/context/time-context/tsconfig.json +++ b/packages/context/time-context/tsconfig.json @@ -9,7 +9,7 @@ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, - { "path": "../../core/system-prompt" }, + { "path": "../../llm/llm" }, { "path": "../../core/agent" } ] } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 82f262f34b..0132a64eb8 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -2182,7 +2182,10 @@ describe('dynamic nested workspace context injection', () => { agent.session.append('user/message', { content: [{ type: 'text', text: 'compacted summary' }], source: { kind: 'plugin', plugin: 'compact' }, - }, { surfaceOp: { op: 'replace', start: contextSeq, end: contextSeq } }) + }, { + surfaceOp: { op: 'replace', start: contextSeq, end: contextSeq }, + sourceEventSeqs: [contextSeq], + }) const afterCompact = await ctx.tools.execute({ callId: CallId('read-after-compact'), diff --git a/packages/cordis/tool-cordis/package.json b/packages/cordis/tool-cordis/package.json index fd9c35e48e..e13de3e58b 100644 --- a/packages/cordis/tool-cordis/package.json +++ b/packages/cordis/tool-cordis/package.json @@ -33,6 +33,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index e360c6c8e1..ba9873f56f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -91,6 +91,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'abstract start(spec: BashExecSpec): BashProcess', ], }, + { + key: 'bashEnv', + summary: 'Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables.', + methods: [ + 'register(contributor: BashEnvContributor): () => void', + 'collect(execution: ToolExecution): DshEnvironment', + 'list(): BashEnvVariableInfo[]', + ], + }, { key: 'codeRuntime', summary: 'Registers one `ctx.codeRuntime` implementation.', @@ -150,6 +159,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'sessionPersistence', summary: 'Durable append-only session storage.', methods: [ + 'abstract locate(meta: SessionHeader): SessionLocation | undefined', 'abstract create(meta: SessionHeader): Promise', 'abstract append(id: SessionId, events: readonly SessionEvent[]): Promise', 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', @@ -158,10 +168,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', ], }, @@ -189,6 +201,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.', @@ -566,13 +585,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AssembledSection', declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n}', }, + { + name: 'BashEnvContributor', + declaration: 'export interface BashEnvContributor {\n name: string;\n variables: Readonly>;\n resolve(execution: ToolExecution): Readonly>>;\n}', + }, + { + name: 'BashEnvVariable', + declaration: 'export interface BashEnvVariable {\n description: string;\n}', + }, + { + name: 'BashEnvVariableInfo', + declaration: 'export interface BashEnvVariableInfo extends BashEnvVariable {\n contributor: string;\n key: DshEnvironmentKey;\n}', + }, { 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 dshEnv?: DshEnvironment | 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 dshEnv?: DshEnvironment | undefined;\n sandboxMode: SandboxMode | undefined;\n}', }, { name: 'BashProcess', @@ -670,6 +701,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DiffResultView', declaration: 'export interface DiffResultView {\n card: \'diff\';\n title?: string;\n diffs: FileDiff[];\n}', }, + { + name: 'DshEnvironment', + declaration: 'export type DshEnvironment = Readonly>;', + }, + { + name: 'DshEnvironmentKey', + declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;', + }, { name: 'FileDiff', declaration: 'export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n}', @@ -798,6 +837,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;', @@ -826,6 +869,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;', @@ -846,6 +897,18 @@ 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: 'SessionLocation', + declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}', + }, { name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', @@ -882,6 +945,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};', diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index 94331df7c0..8892dd47e5 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as ToolCordis from '../src/index.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { REVERSE_TOOL_CODE } from './helpers.ts' @@ -20,11 +17,7 @@ import { REVERSE_TOOL_CODE } from './helpers.ts' async function harness(adapter: MockAdapter): Promise { const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(ToolCordis) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 46d79f02e7..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,7 +49,7 @@ 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`) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 6fd2875036..9b3312c5e9 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -15,7 +15,7 @@ import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } 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 @@ -250,13 +213,15 @@ export function renderContextContent( */ 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 @@ -285,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) @@ -296,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) } @@ -356,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 @@ -382,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) { 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/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 93f44eecf5..a598e1f2bd 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -317,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() } @@ -431,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, @@ -443,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', () => { @@ -484,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/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/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index c778ab6c2e..5809e9ab49 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -28,6 +28,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron | `model` | (required) | the per-session agent template the bridge creates agents from | | `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | +| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery | | `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` | | `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` | | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index e52b21b9f6..c6a58af2f0 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -38,6 +38,8 @@ export interface Config { toolOrder?: string[] /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */ tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string /** 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`. */ @@ -61,6 +63,7 @@ export const Config: z = z.object({ // schemastery's native [] default would read as an invalid configured list. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), tools: ToolRegistry.Config, + dshHome: z.string(), // 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'), diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 34dd0054cc..a86d1261e8 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -118,8 +118,9 @@ describe('dsh-acp-demo composition', () => { }) }) - it('forwards skill config into agent-spine-demo', async () => { - const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6), workspaceContext: false }) + it('forwards skill config and dshHome into agent-spine-demo', async () => { + const skills = await isolatedSkillsConfig(6) + const ctx = await mount({ model: 'mock', persona: 'hi', dshHome: skills.local!.dshHome!, skills, 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() diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 0d02d5f597..45797a58c8 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -42,11 +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?, workspaceContext, toolBash?, toolTasks? } +// { agents?, persona?, toolOrder?, tools?, dshHome?, 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; 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. +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. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `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 505e93e46f..772d6c059b 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -26,6 +26,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-home": "^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", @@ -45,6 +46,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 5652078a0f..d8357c6c9a 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -25,6 +25,7 @@ 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' +import { resolveDshHome } from '@deepseek-ai/dsh-home' export const name = 'agent-spine-demo' @@ -44,13 +45,14 @@ 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`), - * `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. + * `dshHome` to bash environment and local skill discovery, `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 +63,8 @@ export interface Config { toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ tools?: ToolsConfig + /** DeepSeek Harness home directory shared by shell context and local skill discovery. */ + dshHome?: string /** 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. */ @@ -90,11 +94,12 @@ export const Config = z.intersect([ SystemPrompt.Config, z.object({ tools: ToolRegistry.Config, + dshHome: z.string(), skills: SkillConfigSchema, workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), toolBash: ToolBashConfigSchema, toolTasks: ToolTasksConfigSchema, - }) as unknown as z>, + }) as unknown as z>, ]) as unknown as z /** @@ -107,6 +112,7 @@ export function pickSpineConfig(config: Omit): Omit): Omit { await ctx.fiber.dispose() }) + it('shares top-level dshHome between local skills and the managed bash environment', async () => { + const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-home-')) + const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-agents-')) + await mkdir(join(home, 'skills'), { recursive: true }) + await writeFile(join(home, 'skills', 'shared-skill.md'), '---\nname: shared-skill\ndescription: Shared home skill\n---\n\nShared body.\n') + + const ctx = await mount({ + dshHome: home, + workspaceContext: false, + skills: { local: { agentsHome } }, + }, true) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['shared-skill']) + const execution: ToolExecution = { + token: Symbol('agent-core-dsh-home-test') as ToolExecution['token'], + callId: CallId('agent-core-dsh-home'), + name: 'bash', + arguments: { command: 'true' }, + } + expect(ctx.bashEnv.collect(execution)).toMatchObject({ DSH_HOME: home, DSH_SHELL: '1' }) + await ctx.fiber.dispose() + }) + + it('rejects conflicting global and nested DSH home directories', () => { + expect(() => { + agentCore.apply(new Context(), { + dshHome: '/global-dsh-home', + workspaceContext: false, + skills: { local: { dshHome: '/nested-dsh-home' } }, + }) + }).toThrow(/must resolve to the same directory/) + }) + 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 { @@ -307,6 +341,7 @@ describe('dsh-agent-spine-demo bundle', () => { persona: 'You are merged.', toolOrder: ['zulu'], tools: { mode: 'native' as const }, + dshHome: '/tmp/dsh-home', workspaceContext: false as const, skills: {}, toolBash: { enableRunInBackground: false }, @@ -317,6 +352,7 @@ describe('dsh-agent-spine-demo bundle', () => { persona: appConfig.persona, toolOrder: appConfig.toolOrder, tools: appConfig.tools, + dshHome: appConfig.dshHome, workspaceContext: false, skills: {}, toolBash: appConfig.toolBash, diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index 9a51ffa8c8..89cb2accd8 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -50,6 +50,9 @@ { "path": "../../support/invariants" }, + { + "path": "../../util/home" + }, { "path": "../../bash/tool-bash" }, diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index d469acf6c4..5719eaff9f 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -28,6 +28,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `model` | (required) | the pre-created `main` agent's model | | `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | +| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery | | `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` | | `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` | | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index d7ae866b53..04c06ad5db 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -43,6 +43,8 @@ export interface Config { toolOrder?: string[] /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */ tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ @@ -71,6 +73,7 @@ export const Config: z = z.object({ // schemastery's native [] default would read as an invalid configured list. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), tools: ToolRegistry.Config, + dshHome: z.string(), // TODO(single-default-literal): share these schema defaults and defensive // apply() fallbacks through named constants while retaining both boundaries. persistenceRoot: z.string().default('./.sessions'), diff --git a/packages/examples/stdio-demo/tests/built-bin.e2e.ts b/packages/examples/stdio-demo/tests/built-bin.e2e.ts index ad2ab5239d..de953ac77e 100644 --- a/packages/examples/stdio-demo/tests/built-bin.e2e.ts +++ b/packages/examples/stdio-demo/tests/built-bin.e2e.ts @@ -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) @@ -77,6 +94,7 @@ async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promi ' persona: \'demo\'', ' workspaceContext: false', ` welcome: '${welcome}'`, + ...extraEntries, ...disabledBrokenEntry ? ['- id: off', ' name: \'./src/does-not-exist.ts\'', ' disabled: true'] : [], @@ -148,6 +166,27 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-demo BUILT bin (node lib/bin.j expect(code).toBe(0) }, 30_000) + it('boots when optional spill plugins are loaded from a built consumer install', async () => { + consumer = await makeConsumer( + 'SPILL-OK ready.', + false, + ['spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention'], + [ + '- id: spill-local', + ' name: \'@deepseek-ai/dsh-spill-local\'', + '- id: spill-policy', + ' name: \'@deepseek-ai/dsh-spill-policy\'', + ' config:', + ' maxInlineBytes: 50000', + ], + ) + const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', '') + expect(stderr).not.toContain('failed to load') + expect(stderr).not.toContain('Cannot find package') + expect(stdout).toContain('SPILL-OK ready.') + expect(code).toBe(0) + }, 30_000) + it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => { // boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config // directory cannot break its import; the include plugin's own read must fail loud instead. diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts index 1dd9fda575..c0557ca512 100644 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts @@ -132,8 +132,9 @@ describe('dsh-stdio-demo app', () => { await ctx.fiber.dispose() }) - it('forwards skill config into agent-spine-demo', async () => { - const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6), workspaceContext: false }) + it('forwards skill config and dshHome into agent-spine-demo', async () => { + const skills = await isolatedSkillsConfig(6) + const ctx = await mount({ model: 'mock', persona: 'hi', dshHome: skills.local!.dshHome!, skills, 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() 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/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 5b0d439238..79ebda87fb 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -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/package.json b/packages/fs/tool-fs/package.json index c21c806e98..f9ef3136bf 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -36,6 +36,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts index 0487962922..3666447ad9 100644 --- a/packages/fs/tool-fs/tests/harness.ts +++ b/packages/fs/tool-fs/tests/harness.ts @@ -1,10 +1,7 @@ import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' @@ -17,11 +14,7 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' */ export async function fsHarness(fsCwd: string, persona = ''): Promise { const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona }) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona } }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) await ctx.plugin(LocalFileSystem, { cwd: fsCwd }) diff --git a/packages/guard/repeat-tool-guard/package.json b/packages/guard/repeat-tool-guard/package.json index 0cc99b6976..92d49c548f 100644 --- a/packages/guard/repeat-tool-guard/package.json +++ b/packages/guard/repeat-tool-guard/package.json @@ -32,9 +32,9 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } 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 5be98256b0..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 @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId } from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard' import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -21,11 +21,7 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent /** Boot the core spine + the guard; the caller registers adapters and extra listeners. */ async function harness(config: Config = {}): Promise { const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(RepeatToolGuard, config) ctx.tools.register(defineTool({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) @@ -363,11 +359,7 @@ describe('fold onto the downstream decision', () => { describe('config validation fails loud', () => { async function spine(): Promise { const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) return ctx } 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 700acadda8..4430f5511a 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -46,6 +46,8 @@ The three emit points run detached — no seam awaits a `SessionStart`/`Subagent The matcher subject is the tool name (`PreToolUse`/`PostToolUse`), the session source (`SessionStart`), or a constant `agent_type` of `general-purpose` (`SubagentStart`/`SubagentStop` — the harness subagent seam carries no per-kind label, so the bridge reports Claude Code's own Task-tool default; a default/`*`/empty `agent_type` matcher fires, a specific-kind matcher does not); `UserPromptSubmit`/`Stop` ignore matchers. Multiple file-configured hooks on one point run **serially, in config order**, and fold most-restrictively (`deny > ask > allow`, see `dsh-hook-protocol`); serial keeps each hook's `hook/invoked`/`hook/result` pair adjacent in the log, and the fold is order-independent for the decision (see the RFC's "run serially, not concurrently" note). +Every agent-scoped stdin payload carries `session_id` and string-shaped `transcript_path`. The bridge resolves the latter through `ctx.sessionPersistence.locate(session.header)` when available and otherwise sends `''`. Lookup does not create or flush the artifact, so a path can be absent before the first turn-end checkpoint or omit the current open turn. + ## Context source Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }` source. `agent.inject()` defaults a missing source to `{ kind: 'user' }`, which would mislabel plugin context as a user prompt — so the bridge always names itself. diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index 21f08965d8..c6870a6471 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -29,6 +29,7 @@ "@deepseek-ai/dsh-hook-protocol": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -36,13 +37,15 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 25f959260c..121e5cf75a 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -14,6 +14,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-session-persistence' import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { appendHookInvoked, @@ -197,7 +198,7 @@ export function apply(ctx: Context, config: Config): void { // may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. ctx.on('agent/session-start', (agent, source) => { - detached.track(runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: detached.signal }) + detached.track(runPoint('SessionStart', source, sessionStartPayload(ctx, agent, source), { agent, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) if (context) agent.inject(context.content, { source: context.source }) @@ -211,7 +212,7 @@ export function apply(ctx: Context, config: Config): void { // matcher subject (CC ignores matchers for this event). --- ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise => { const turn = lastTurn(agent) - const merged = await runPoint('UserPromptSubmit', '', promptPayload(agent, content), { agent, turn }) + const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn }) if (merged.decision === 'deny') { return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } } @@ -230,7 +231,7 @@ export function apply(ctx: Context, config: Config): void { // --- PreToolUse → PreToolDecision. Matcher subject is the tool name. --- ctx.on('tools/pre-execute', async (exec, next): Promise => { const turn = lastTurn(exec.agent) - const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} } return next() @@ -239,7 +240,7 @@ export function apply(ctx: Context, config: Config): void { // --- PostToolUse → PostToolDecision. Matcher subject is the tool name. --- ctx.on('tools/post-execute', async (exec, result, next): Promise => { const turn = lastTurn(exec.agent) - const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, 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 ? { additionalContexts: [context] } : {} } @@ -261,7 +262,7 @@ export function apply(ctx: Context, config: Config): void { // A blocking Stop hook forces continuation with its reason. // TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile. ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise => { - const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn }) + const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn }) if (merged.decision === 'deny') { // A blocking Stop hook forces continuation. const text = merged.reason ?? 'continue: blocked by Stop hook' @@ -274,7 +275,7 @@ export function apply(ctx: Context, config: Config): void { // use the live child's workspace and the generic agent-type matcher subject. ctx.on('subagent/start', (info) => { const child = ctx.get('agents')?.get(info.id) - detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }) + detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) if (context && child) child.inject(context.content, { source: context.source }) @@ -286,7 +287,7 @@ export function apply(ctx: Context, config: Config): void { // `.then` before the tool caller's `await run.result` disposes it) so the hook runs in the // child's cwd, not the server default. const child = ctx.get('agents')?.get(info.id) - detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })) + detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })) }) } @@ -316,28 +317,31 @@ function blocksToText(content: ContentBlock[]): string { return content.filter((b): b is Extract => b.type === 'text').map(b => b.text).join('') } -function base(agent: Agent | undefined, event: string): Record { +function base(ctx: Context, agent: Agent | undefined, event: string): Record { return { session_id: agent?.session.header.id ?? '', + transcript_path: agent === undefined + ? '' + : ctx.get('sessionPersistence')?.locate(agent.session.header)?.path ?? '', cwd: agent?.session.header.cwd ?? process.cwd(), hook_event_name: event, } } -function sessionStartPayload(agent: Agent, source: string): Record { - return { ...base(agent, 'SessionStart'), source } +function sessionStartPayload(ctx: Context, agent: Agent, source: string): Record { + return { ...base(ctx, agent, 'SessionStart'), source } } -function promptPayload(agent: Agent, content: ContentBlock[]): Record { - return { ...base(agent, 'UserPromptSubmit'), prompt: blocksToText(content) } +function promptPayload(ctx: Context, agent: Agent, content: ContentBlock[]): Record { + return { ...base(ctx, agent, 'UserPromptSubmit'), prompt: blocksToText(content) } } -function preToolPayload(exec: ToolExecution): Record { - return { ...base(exec.agent, 'PreToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId } +function preToolPayload(ctx: Context, exec: ToolExecution): Record { + return { ...base(ctx, exec.agent, 'PreToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId } } -function postToolPayload(exec: ToolExecution, result: ToolExecutionResult): Record { - return { ...base(exec.agent, 'PostToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } +function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): Record { + return { ...base(ctx, exec.agent, 'PostToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } } -function stopPayload(agent: Agent): Record { - return { ...base(agent, 'Stop'), stop_hook_active: false } +function stopPayload(ctx: Context, agent: Agent): Record { + return { ...base(ctx, agent, 'Stop'), stop_hook_active: false } } /** * Build a SubagentStart/SubagentStop payload from the CC base (the child's @@ -345,9 +349,9 @@ function stopPayload(agent: Agent): Record { * fields. `agent_type` is the CC-default {@link SUBAGENT_TYPE}; `stop_hook_active` * is present on SubagentStop only (the loop-guard flag, always false this cut). */ -function subagentPayload(event: 'SubagentStart' | 'SubagentStop', info: { id: string }, child: Agent | undefined): Record { +function subagentPayload(ctx: Context, event: 'SubagentStart' | 'SubagentStop', info: { id: string }, child: Agent | undefined): Record { return { - ...base(child, event), + ...base(ctx, child, event), agent_id: info.id, agent_type: SUBAGENT_TYPE, ...event === 'SubagentStop' ? { stop_hook_active: false } : {}, diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 506d613c73..6d9e89d5ed 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -4,12 +4,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context, type Fiber } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -42,11 +41,7 @@ async function harness(configDir: string, adapter: MockAdapter): Promise { const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') }) @@ -337,11 +332,7 @@ describe('hooks-claude bridge — load resilience', () => { it('a missing config file registers no hooks and does not crash the loop', async () => { const adapter = new MockAdapter([textResponse('fine')]) const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' }) @@ -359,11 +350,7 @@ describe('hooks-claude bridge — load resilience', () => { const dir = writeConfig({ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') }) diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts new file mode 100644 index 0000000000..a08f9b5b96 --- /dev/null +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -0,0 +1,741 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +/** Targeted branch coverage for the CC bridge: option arms, warn paths, no-agent + * fallbacks, contextFrom-empty, and the detached-listener catch handlers. */ + +const dirs: string[] = [] +afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) + +function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hc-cov-')); dirs.push(d); return d } +function sh(d: string, name: string, body: string): string { + const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p +} +function hooks(d: string, h: unknown): string { + writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json') +} + +type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number; sessionRoot?: string } +async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + await ctx.plugin(HooksClaude, { configPath, ...opts }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { + return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) +} +function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +/** Poll until `predicate` holds or the deadline passes — robust to detached + * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ +async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { + const deadline = Date.now() + timeout + while (!predicate()) { + if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline') + await new Promise(r => setTimeout(r, interval)) + } +} + +export type CoverageGroup = 'config' | 'stop' | 'context' | 'edge-paths' + +/** Register independently schedulable slices of the hooks-claude coverage matrix. */ +export function defineCoverageCases(group: CoverageGroup): void { + if (group === 'config') describe('hooks-claude coverage — config option arms + substitution + skip warning', () => { + it('uses the persistence locator for transcript_path and an empty string without one', async () => { + async function capture(sessionRoot?: string): Promise<{ payload: { transcript_path: string }; expected: string | undefined }> { + const d = dir() + const cap = join(d, 'payload') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('transcript'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + return { + payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string }, + expected: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path, + } + } + + const located = await capture(dir()) + expect(located.payload.transcript_path).toBe(located.expected) + expect((await capture()).payload.transcript_path).toBe('') + }, 15_000) // Two real agent/hook subprocess loops need loaded pre-push runner headroom. + + it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => { + const d = dir() + // ${CLAUDE_PLUGIN_ROOT} resolves to d; the script writes its own cwd-independent marker. + const marker = join(d, 'ran') + sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) + const path = hooks(d, { + PreToolUse: [{ hooks: [ + { type: 'prompt', prompt: 'skipme' }, // skipped → warn loop + { type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/h.sh' }, // substituted + ] }], + }) + const warn = vi.fn() + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d }) + ctx.logger.warn = warn as never + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(existsSync(marker)).toBe(true) // substituted command ran + }) + + it('warns and honors updatedInput as a no-op (input rewrite deferred)', async () => { + const d = dir() + const s = sh(d, 'u.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"rewritten"}}}\'\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const warn = vi.fn() + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { command: 'original' }), textResponse('done')]) + const ctx = await harness(path, adapter) + ctx.logger.warn = warn as never + let sawArgs: unknown + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // updatedInput is NOT honored — the tool ran with the ORIGINAL args. + expect((sawArgs as { command?: string }).command).toBe('original') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('updatedInput')) + }) + }) + + if (group === 'config') describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () => { + it('a clean exit-0 hook with no output is a no-op (contextFrom empty → next())', async () => { + const d = dir() + const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ran')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // The prompt proceeded unchanged; no context/message injected. + expect(adapter.requests).toHaveLength(1) + expect(events(agent).some(e => e.type === 'context/message')).toBe(false) + }) + + it('a PreToolUse hook fires for a no-agent direct tool call (no session/turn to record into)', async () => { + const d = dir() + const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\necho "no" >&2\nexit 2\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const ctx = await harness(path, new MockAdapter([])) + let ran = false + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + // Call execute() directly with NO agent — the bridge's no-agent/no-turn path. + const { CallId } = await import('@deepseek-ai/dsh-llm') + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} }) + expect(ran).toBe(false) + expect(result.isError).toBe(true) + }) + + it('a long stderr is truncated in the hook/result summary', async () => { + const d = dir() + // Emit >500 chars of stderr then exit 2. + const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') + const path = hooks(d, { PreToolUse: [{ 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' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) + expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis + }) + + it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => { + const d = dir() + const path = hooks(d, {}) + for (const bad of [0, -5, 1.5, Number.NaN]) { + const adapter = new MockAdapter([]) + await expect(harness(path, adapter, { stderrSummaryMaxChars: bad })) + .rejects.toThrow(/hooks-claude: stderrSummaryMaxChars must be a positive integer/) + } + }) + + it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => { + const d = dir() + const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') + }) + }) + + if (group === 'stop') describe('hooks-claude coverage — Stop continuation + subagent inject/catch', () => { + it('a Stop hook that blocks (exit 2) forces the turn to continue (CC dialect)', async () => { + const d = dir() + const marker = join(d, 'fired') + const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "continue please" >&2\nexit 2\n`) + const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please') + }) + + it('a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)', async () => { + // A blocking Stop hook with no stderr yields `deny` without a reason. The block still forces + // continuation; the script self-limits to one block to avoid a loop. + const d = dir() + const marker = join(d, 'fired') + const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) + const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // A second model request ran → the empty-reason block forced continuation. + expect(adapter.requests).toHaveLength(2) + // The steering carried the fallback reason (no stderr to use). + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') + }) + + it('SubagentStart additionalContext is injected into a REGISTERED live child', async () => { + const d = dir() + const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child guidance"}}\'\n') + const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) + const ctx = await harness(path, new MockAdapter([])) + // Register a fake child agent under the id the event carries. + const injected: string[] = [] + const child = { id: AgentId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters[0] + ctx.agents.register(child) + ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-x') }) + await waitFor(() => injected.includes('child guidance')) + expect(injected).toContain('child guidance') + }) + + it('a throwing SubagentStart/SubagentStop hook run is contained (logged)', async () => { + const d = dir() + // A hook command that does not exist makes runHook resolve a non-blocking + // error (not a throw), so to hit the .catch we make the .then throw: register + // a child whose inject throws for SubagentStart. + const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"x"}}\'\n') + const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) + const ctx = await harness(path, new MockAdapter([])) + const warn = vi.fn(); ctx.logger.warn = warn as never + const child = { id: AgentId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters[0] + ctx.agents.register(child) + ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-y') }) + await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed'))) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) + }) + }) + + if (group === 'stop') describe('hooks-claude coverage — default reasons + sparse payloads', () => { + it('PreToolUse deny with EMPTY stderr uses the default reason', async () => { + const d = dir() + const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') // exit 2, no stderr + const path = hooks(d, { PreToolUse: [{ 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: 'x' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) + }) + + it('PostToolUse deny with EMPTY stderr + no context uses the default feedback', async () => { + const d = dir() + const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\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' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) + }) + + it('SubagentStop with no registered child runs the hook cleanly (fire-and-forget)', async () => { + const d = dir() + // The agents registry has no entry for the id, so the child lookup yields + // undefined and the payload falls back to base(undefined) — assert the + // observe-only SubagentStop run still executes the hook without crashing. + const marker = join(d, 'stopran') + const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) + const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) + const ctx = await harness(path, new MockAdapter([])) + ctx.emit('subagent/end', { provider: 'p', id: AgentId('child-z'), stopReason: 'completed' }) + await waitFor(() => existsSync(marker)) + expect(existsSync(marker)).toBe(true) + }) + }) + + if (group === 'edge-paths') describe('hooks-claude coverage — more default/sparse arms', () => { + it('UserPromptSubmit deny with EMPTY stderr uses the default block reason', async () => { + const d = dir() + const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('no')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const turnEnd = events(agent).findLast(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook') + }) + + it('a PreToolUse ask with NO reason omits the reason (false arm)', async () => { + const d = dir() + const s = sh(d, 'ask.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask"}}\'\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // ask (no reason) → degrades to deny with the registry's generic message. + expect(ran).toBe(false) + expect(events(agent).some(e => e.type === 'tool/result' && e.data.isError)).toBe(true) + }) + + it('a recorded clean exit-0 hook with no stderr omits exitCode-extra/stderrSummary fields', async () => { + const d = dir() + const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n') + const path = hooks(d, { PreToolUse: [{ 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' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) + expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false) + }) + }) + + if (group === 'edge-paths') describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () => { + it('a direct apply() (schema bypass) with only configPath runs', async () => { + const d = dir() + const marker = join(d, 'ran') + const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + // Direct apply with only configPath — bypasses schemastery's defaults, so + // the bridge must run on the raw minimal config (the per-hook timeout is + // the protocol lib's reference default, not a config knob). + HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(existsSync(marker)).toBe(true) + }) + + it('a non-zero non-2 hook exit (e.g. a command-not-found 127) is a non-blocking error; the tool still runs', async () => { + const d = dir() + // `bash -c` of a missing program exits 127 — a non-blocking error (not 0, not + // 2 → no decision), so the tool proceeds; the hook/result records exit 127. + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: '/nonexistent/definitely/not/a/command' }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(ran).toBe(true) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.exitCode).toBe(127) + }) + + it('a PostToolUse deny with empty stderr + no context uses the default feedback (no context arm)', async () => { + const d = dir() + const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\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' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + }) + }) + + if (group === 'context') describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => { + it('a {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))', async () => { + // The seams cannot yet honor `continue:false` as a hard halt. The log must still record the + // stop decision while execution and the turn continue normally. + const d = dir() + const s = sh(d, 'stop.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded + expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred) + const turnEnd = events(agent).findLast(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') // ran to completion + }) + + it('a PostToolUse hook that BOTH blocks AND attaches additionalContext', async () => { + const d = dir() + const s = sh(d, 'b.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"context too"}}\'\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' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) + // additionalContext also injected (the block + context arm). + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true) + }) + + it('a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool', async () => { + // The block's hookEventName (UserPromptSubmit) mismatches the firing event + // (PreToolUse), so its permissionDecision:"deny" is discarded — the tool runs. + const d = dir() + const s = sh(d, 'x.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","permissionDecision":"deny"}}\'\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran + }) + + it('defaults CLAUDE_PROJECT_DIR to the session workspace when no projectDir is configured', async () => { + // The default ACP wiring sets no projectDir. A stock CC hook that references + // $CLAUDE_PROJECT_DIR (shell expansion) must still get the session workspace, + // not an empty string. The hook echoes the var as additionalContext. + const d = dir() + const workspace = dir() + const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\nprintf \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"dir=%s"}}\' "$CLAUDE_PROJECT_DIR"\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ran')]) + const ctx = await harness(path, adapter) // NB: no projectDir + // The factory create() path honors meta.cwd (the plain agentLoop.create() does not). + const { SessionId } = await import('@deepseek-ai/dsh-session') + const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } }) + handle.agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, handle.agent as ReactLoopAgent) + expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message' + && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true) + await handle.dispose() + }) + + it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => { + // A context-only hook delegates with `next()` and folds its context, so a downstream policy + // listener can still veto the prompt. + const d = dir() + const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('should not run')]) + const ctx = await harness(path, adapter) + // A later listener that blocks every prompt (registered AFTER the bridge). + const { AgentId: AId } = await import('@deepseek-ai/dsh-agent') + ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) + const agent = ctx.agentLoop.create(AId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + // the downstream block won: the model was never called, no user/message was + // recorded, and the (sole, fully-blocked) prompt closed the turn `rejected` + expect(adapter.requests).toHaveLength(0) + expect(events(agent).some(e => e.type === 'user/message')).toBe(false) + const turnEnd = events(agent).findLast(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' }) + }) + + 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 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 }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(path, adapter) + ctx.on('agent/prompt-submit', async () => ({ + kind: 'allow' as const, + content: [{ type: 'text' as const, text: 'rewritten-prompt' }], + 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) + const req = JSON.stringify(adapter.requests[0]!.messages) + expect(req).toContain('from-bridge') + expect(req).toContain('from-downstream') + expect(req).toContain('rewritten-prompt') // downstream content rewrite preserved + // 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 () => { + // The bridge hook adds context; a later post-execute listener accepts with a + // content rewrite. Both the rewrite and the bridge context survive. + 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, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) + 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 + // block arm). + 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: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) + // the bridge's context still landed (folded onto the block) + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) + }) + + }) + + if (group === 'edge-paths') describe('hooks-claude coverage — executor reject + no-open-turn', () => { + it('when the bash executor REJECTS a hook run, the hook/result omits exitCode (non-blocking)', async () => { + const d = dir() + const s = sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter) + // Force the executor to reject (an infrastructure fault) so runHook's catch + // yields a HookOutput with exitCode undefined → the `exitCode` spread false arm. + const bash = ctx.bash + bash.run = (() => Promise.reject(new Error('executor down'))) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) + }) + + }) + + if (group === 'edge-paths') describe('hooks-claude coverage — detached-listener catch handlers', () => { + it('a throwing SessionStart inject is contained (logged, agent still runs)', async () => { + const d = dir() + const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n') + const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + // Make inject throw, forcing the SessionStart .catch path. + const original = agent.inject.bind(agent) + let threw = false + agent.inject = (() => { threw = true; throw new Error('inject boom') }) + await waitFor(() => threw) + expect(threw).toBe(true) + agent.inject = original + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject + }) + }) + + if (group === 'stop') describe('hooks-claude coverage — hook runs in the session cwd, not the server cwd', () => { + it('runs an agent-scoped hook in the session workspace even when the executor default differs', async () => { + // The server launch directory and session cwd deliberately differ. The marker proves the + // bridge passes `session/new.cwd` instead of falling back to the executor default. + const serverDir = dir() + const sessionDir = dir() + const marker = join(sessionDir, 'where') + // The hook is invoked with cwd = session dir, so a relative marker path lands there. + hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + // Executor default cwd = serverDir (deliberately NOT the session cwd). + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) + await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) + ctx.llm.registerAdapter(['mock'], adapter) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + + const { SessionId } = await import('@deepseek-ai/dsh-session') + const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) + handle.agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, handle.agent as ReactLoopAgent) + + expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir + const { readFileSync } = await import('node:fs') + const where = readFileSync(marker, 'utf8').trim() + // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. + expect(where.endsWith(sessionDir.split('/').pop()!)).toBe(true) + await handle.dispose() + }) + + it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => { + // `SubagentStop` recovers the child at `subagent/end`; a relative marker proves `runPoint` + // receives that agent and runs in the child's cwd rather than the executor default. + const serverDir = dir() + const childDir = dir() + const marker = join(childDir, 'stopwhere') + hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'pwd > stopwhere' }] }] }) + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + // Executor default cwd = serverDir (deliberately NOT the child session cwd). + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) + await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([])) + + // Register a live child on its own session cwd; emit subagent/end with its id. + const { SessionId } = await import('@deepseek-ai/dsh-session') + const childHandle = await ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } }) + ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' }) + + await waitFor(() => existsSync(marker)) + expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir + const { readFileSync } = await import('node:fs') + const where = readFileSync(marker, 'utf8').trim() + // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. + expect(where.endsWith(childDir.split('/').pop()!)).toBe(true) + await childHandle.dispose() + }) + }) + + if (group === 'config') describe('hooks-claude coverage — systemMessage is warned, not surfaced', () => { + it('a hook emitting a systemMessage is logged as not-yet-surfaced', async () => { + const d = dir() + const s = sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n') + const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(path, adapter) + const warn = vi.fn(); ctx.logger.warn = warn as never + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) + // Not surfaced: the systemMessage text never reaches the model request. + expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') + }) + }) + + if (group === 'edge-paths') describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait)', () => { + it('does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)', async () => { + // Session-start injection is detached, so an immediate prompt need not observe it. Assert only + // the guaranteed behavior—no crash and a completed turn—without pre-waiting away the race. + const d = dir() + const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n') + const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(path, adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + // Send immediately — do NOT wait for the session-start inject. + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing + }) + }) +} diff --git a/packages/hooks/hooks-claude/tests/coverage-config.spec.ts b/packages/hooks/hooks-claude/tests/coverage-config.spec.ts new file mode 100644 index 0000000000..1afa18c4ff --- /dev/null +++ b/packages/hooks/hooks-claude/tests/coverage-config.spec.ts @@ -0,0 +1,3 @@ +import { defineCoverageCases } from './coverage-cases.ts' + +defineCoverageCases('config') diff --git a/packages/hooks/hooks-claude/tests/coverage-context.spec.ts b/packages/hooks/hooks-claude/tests/coverage-context.spec.ts new file mode 100644 index 0000000000..e0f3fb0ef8 --- /dev/null +++ b/packages/hooks/hooks-claude/tests/coverage-context.spec.ts @@ -0,0 +1,3 @@ +import { defineCoverageCases } from './coverage-cases.ts' + +defineCoverageCases('context') diff --git a/packages/hooks/hooks-claude/tests/coverage-edge-paths.spec.ts b/packages/hooks/hooks-claude/tests/coverage-edge-paths.spec.ts new file mode 100644 index 0000000000..0bbcb53b03 --- /dev/null +++ b/packages/hooks/hooks-claude/tests/coverage-edge-paths.spec.ts @@ -0,0 +1,3 @@ +import { defineCoverageCases } from './coverage-cases.ts' + +defineCoverageCases('edge-paths') diff --git a/packages/hooks/hooks-claude/tests/coverage-stop.spec.ts b/packages/hooks/hooks-claude/tests/coverage-stop.spec.ts new file mode 100644 index 0000000000..651cb1f6f0 --- /dev/null +++ b/packages/hooks/hooks-claude/tests/coverage-stop.spec.ts @@ -0,0 +1,3 @@ +import { defineCoverageCases } from './coverage-cases.ts' + +defineCoverageCases('stop') diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts deleted file mode 100644 index 0b251ece83..0000000000 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ /dev/null @@ -1,729 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' -import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' - -/** Targeted branch coverage for the CC bridge: option arms, warn paths, no-agent - * fallbacks, contextFrom-empty, and the detached-listener catch handlers. */ - -const dirs: string[] = [] -afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) - -function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hc-cov-')); dirs.push(d); return d } -function sh(d: string, name: string, body: string): string { - const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p -} -function hooks(d: string, h: unknown): string { - writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json') -} - -type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number } -async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise { - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - await ctx.plugin(HooksClaude, { configPath, ...opts }) - ctx.llm.registerAdapter(['mock'], adapter) - return ctx -} -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { - return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) -} -function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } -/** Poll until `predicate` holds or the deadline passes — robust to detached - * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ -async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { - const deadline = Date.now() + timeout - while (!predicate()) { - if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline') - await new Promise(r => setTimeout(r, interval)) - } -} - -describe('hooks-claude coverage — config option arms + substitution + skip warning', () => { - it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => { - const d = dir() - // ${CLAUDE_PLUGIN_ROOT} resolves to d; the script writes its own cwd-independent marker. - const marker = join(d, 'ran') - sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) - const path = hooks(d, { - PreToolUse: [{ hooks: [ - { type: 'prompt', prompt: 'skipme' }, // skipped → warn loop - { type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/h.sh' }, // substituted - ] }], - }) - const warn = vi.fn() - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d }) - ctx.logger.warn = warn as never - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - expect(existsSync(marker)).toBe(true) // substituted command ran - }) - - it('warns and honors updatedInput as a no-op (input rewrite deferred)', async () => { - const d = dir() - const s = sh(d, 'u.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"rewritten"}}}\'\n') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const warn = vi.fn() - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { command: 'original' }), textResponse('done')]) - const ctx = await harness(path, adapter) - ctx.logger.warn = warn as never - let sawArgs: unknown - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - // updatedInput is NOT honored — the tool ran with the ORIGINAL args. - expect((sawArgs as { command?: string }).command).toBe('original') - expect(warn).toHaveBeenCalledWith(expect.stringContaining('updatedInput')) - }) -}) - -describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () => { - it('a clean exit-0 hook with no output is a no-op (contextFrom empty → next())', async () => { - const d = dir() - const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n') - const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('ran')]) - const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - // The prompt proceeded unchanged; no context/message injected. - expect(adapter.requests).toHaveLength(1) - expect(events(agent).some(e => e.type === 'context/message')).toBe(false) - }) - - it('a PreToolUse hook fires for a no-agent direct tool call (no session/turn to record into)', async () => { - const d = dir() - const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\necho "no" >&2\nexit 2\n') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const ctx = await harness(path, new MockAdapter([])) - let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) - // Call execute() directly with NO agent — the bridge's no-agent/no-turn path. - const { CallId } = await import('@deepseek-ai/dsh-llm') - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} }) - expect(ran).toBe(false) - expect(result.isError).toBe(true) - }) - - it('a long stderr is truncated in the hook/result summary', async () => { - const d = dir() - // Emit >500 chars of stderr then exit 2. - const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') - const path = hooks(d, { PreToolUse: [{ 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' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) - expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis - }) - - it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => { - const d = dir() - const path = hooks(d, {}) - for (const bad of [0, -5, 1.5, Number.NaN]) { - const adapter = new MockAdapter([]) - await expect(harness(path, adapter, { stderrSummaryMaxChars: bad })) - .rejects.toThrow(/hooks-claude: stderrSummaryMaxChars must be a positive integer/) - } - }) - - it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => { - const d = dir() - const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') - }) -}) - -describe('hooks-claude coverage — Stop continuation + subagent inject/catch', () => { - it('a Stop hook that blocks (exit 2) forces the turn to continue (CC dialect)', async () => { - const d = dir() - const marker = join(d, 'fired') - const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "continue please" >&2\nexit 2\n`) - const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) - const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(2) - expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please') - }) - - it('a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)', async () => { - // A blocking Stop hook with no stderr yields `deny` without a reason. The block still forces - // continuation; the script self-limits to one block to avoid a loop. - const d = dir() - const marker = join(d, 'fired') - const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) - const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) - const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - // A second model request ran → the empty-reason block forced continuation. - expect(adapter.requests).toHaveLength(2) - // The steering carried the fallback reason (no stderr to use). - expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') - }) - - it('SubagentStart additionalContext is injected into a REGISTERED live child', async () => { - const d = dir() - const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child guidance"}}\'\n') - const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) - const ctx = await harness(path, new MockAdapter([])) - // Register a fake child agent under the id the event carries. - const injected: string[] = [] - const child = { id: AgentId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters[0] - ctx.agents.register(child) - ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-x') }) - await waitFor(() => injected.includes('child guidance')) - expect(injected).toContain('child guidance') - }) - - it('a throwing SubagentStart/SubagentStop hook run is contained (logged)', async () => { - const d = dir() - // A hook command that does not exist makes runHook resolve a non-blocking - // error (not a throw), so to hit the .catch we make the .then throw: register - // a child whose inject throws for SubagentStart. - const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"x"}}\'\n') - const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) - const ctx = await harness(path, new MockAdapter([])) - const warn = vi.fn(); ctx.logger.warn = warn as never - const child = { id: AgentId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters[0] - ctx.agents.register(child) - ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-y') }) - await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed'))) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) - }) -}) - -describe('hooks-claude coverage — default reasons + sparse payloads', () => { - it('PreToolUse deny with EMPTY stderr uses the default reason', async () => { - const d = dir() - const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') // exit 2, no stderr - const path = hooks(d, { PreToolUse: [{ 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: 'x' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const result = events(agent).find(e => e.type === 'tool/result') - expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) - }) - - it('PostToolUse deny with EMPTY stderr + no context uses the default feedback', async () => { - const d = dir() - const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\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' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const result = events(agent).find(e => e.type === 'tool/result') - expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) - }) - - it('SubagentStop with no registered child runs the hook cleanly (fire-and-forget)', async () => { - const d = dir() - // The agents registry has no entry for the id, so the child lookup yields - // undefined and the payload falls back to base(undefined) — assert the - // observe-only SubagentStop run still executes the hook without crashing. - const marker = join(d, 'stopran') - const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) - const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) - const ctx = await harness(path, new MockAdapter([])) - ctx.emit('subagent/end', { provider: 'p', id: AgentId('child-z'), stopReason: 'completed' }) - await waitFor(() => existsSync(marker)) - expect(existsSync(marker)).toBe(true) - }) -}) - -describe('hooks-claude coverage — more default/sparse arms', () => { - it('UserPromptSubmit deny with EMPTY stderr uses the default block reason', async () => { - const d = dir() - const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n') - const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('no')]) - const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const turnEnd = events(agent).findLast(e => e.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook') - }) - - it('a PreToolUse ask with NO reason omits the reason (false arm)', async () => { - const d = dir() - const s = sh(d, 'ask.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask"}}\'\n') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - // ask (no reason) → degrades to deny with the registry's generic message. - expect(ran).toBe(false) - expect(events(agent).some(e => e.type === 'tool/result' && e.data.isError)).toBe(true) - }) - - it('a recorded clean exit-0 hook with no stderr omits exitCode-extra/stderrSummary fields', async () => { - const d = dir() - const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n') - const path = hooks(d, { PreToolUse: [{ 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' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) - expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false) - }) -}) - -describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () => { - it('a direct apply() (schema bypass) with only configPath runs', async () => { - const d = dir() - const marker = join(d, 'ran') - const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - // Direct apply with only configPath — bypasses schemastery's defaults, so - // the bridge must run on the raw minimal config (the per-hook timeout is - // the protocol lib's reference default, not a config knob). - HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) - ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - expect(existsSync(marker)).toBe(true) - }) - - it('a non-zero non-2 hook exit (e.g. a command-not-found 127) is a non-blocking error; the tool still runs', async () => { - const d = dir() - // `bash -c` of a missing program exits 127 — a non-blocking error (not 0, not - // 2 → no decision), so the tool proceeds; the hook/result records exit 127. - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: '/nonexistent/definitely/not/a/command' }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - expect(ran).toBe(true) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.exitCode).toBe(127) - }) - - it('a PostToolUse deny with empty stderr + no context uses the default feedback (no context arm)', async () => { - const d = dir() - const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\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' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const result = events(agent).find(e => e.type === 'tool/result') - expect(result?.type === 'tool/result' && result.data.isError).toBe(true) - }) -}) - -describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => { - it('a {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))', async () => { - // The seams cannot yet honor `continue:false` as a hard halt. The log must still record the - // stop decision while execution and the turn continue normally. - const d = dir() - const s = sh(d, 'stop.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded - expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred) - const turnEnd = events(agent).findLast(e => e.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') // ran to completion - }) - - it('a PostToolUse hook that BOTH blocks AND attaches additionalContext', async () => { - const d = dir() - const s = sh(d, 'b.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"context too"}}\'\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' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const result = events(agent).find(e => e.type === 'tool/result') - expect(result?.type === 'tool/result' && result.data.isError).toBe(true) - expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) - // additionalContext also injected (the block + context arm). - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true) - }) - - it('a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool', async () => { - // The block's hookEventName (UserPromptSubmit) mismatches the firing event - // (PreToolUse), so its permissionDecision:"deny" is discarded — the tool runs. - const d = dir() - const s = sh(d, 'x.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","permissionDecision":"deny"}}\'\n') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran - }) - - it('defaults CLAUDE_PROJECT_DIR to the session workspace when no projectDir is configured', async () => { - // The default ACP wiring sets no projectDir. A stock CC hook that references - // $CLAUDE_PROJECT_DIR (shell expansion) must still get the session workspace, - // not an empty string. The hook echoes the var as additionalContext. - const d = dir() - const workspace = dir() - const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\nprintf \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"dir=%s"}}\' "$CLAUDE_PROJECT_DIR"\n') - const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('ran')]) - const ctx = await harness(path, adapter) // NB: no projectDir - // The factory create() path honors meta.cwd (the plain agentLoop.create() does not). - const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } }) - handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) - expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message' - && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true) - await handle.dispose() - }) - - it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => { - // A context-only hook delegates with `next()` and folds its context, so a downstream policy - // listener can still veto the prompt. - const d = dir() - const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') - const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('should not run')]) - const ctx = await harness(path, adapter) - // A later listener that blocks every prompt (registered AFTER the bridge). - const { AgentId: AId } = await import('@deepseek-ai/dsh-agent') - ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) - const agent = ctx.agentLoop.create(AId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - // the downstream block won: the model was never called, no user/message was - // recorded, and the (sole, fully-blocked) prompt closed the turn `rejected` - expect(adapter.requests).toHaveLength(0) - expect(events(agent).some(e => e.type === 'user/message')).toBe(false) - const turnEnd = events(agent).findLast(e => e.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' }) - }) - - 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 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 }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(path, adapter) - ctx.on('agent/prompt-submit', async () => ({ - kind: 'allow' as const, - content: [{ type: 'text' as const, text: 'rewritten-prompt' }], - 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) - const req = JSON.stringify(adapter.requests[0]!.messages) - expect(req).toContain('from-bridge') - expect(req).toContain('from-downstream') - expect(req).toContain('rewritten-prompt') // downstream content rewrite preserved - // 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 () => { - // The bridge hook adds context; a later post-execute listener accepts with a - // content rewrite. Both the rewrite and the bridge context survive. - 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, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const result = events(agent).find(e => e.type === 'tool/result') - expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) - 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 - // block arm). - 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: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const result = events(agent).find(e => e.type === 'tool/result') - expect(result?.type === 'tool/result' && result.data.isError).toBe(true) - expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) - // the bridge's context still landed (folded onto the block) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true) - }) - -}) - -describe('hooks-claude coverage — executor reject + no-open-turn', () => { - it('when the bash executor REJECTS a hook run, the hook/result omits exitCode (non-blocking)', async () => { - const d = dir() - const s = sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n') - const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = await harness(path, adapter) - // Force the executor to reject (an infrastructure fault) so runHook's catch - // yields a HookOutput with exitCode undefined → the `exitCode` spread false arm. - const bash = ctx.bash - bash.run = (() => Promise.reject(new Error('executor down'))) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) - }) - -}) - -describe('hooks-claude coverage — detached-listener catch handlers', () => { - it('a throwing SessionStart inject is contained (logged, agent still runs)', async () => { - const d = dir() - const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n') - const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // Make inject throw, forcing the SessionStart .catch path. - const original = agent.inject.bind(agent) - let threw = false - agent.inject = (() => { threw = true; throw new Error('inject boom') }) - await waitFor(() => threw) - expect(threw).toBe(true) - agent.inject = original - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject - }) -}) - -describe('hooks-claude coverage — hook runs in the session cwd, not the server cwd', () => { - it('runs an agent-scoped hook in the session workspace even when the executor default differs', async () => { - // The server launch directory and session cwd deliberately differ. The marker proves the - // bridge passes `session/new.cwd` instead of falling back to the executor default. - const serverDir = dir() - const sessionDir = dir() - const marker = join(sessionDir, 'where') - // The hook is invoked with cwd = session dir, so a relative marker path lands there. - hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - // Executor default cwd = serverDir (deliberately NOT the session cwd). - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) - await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) - ctx.llm.registerAdapter(['mock'], adapter) - ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - - const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) - handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) - - expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir - const { readFileSync } = await import('node:fs') - const where = readFileSync(marker, 'utf8').trim() - // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. - expect(where.endsWith(sessionDir.split('/').pop()!)).toBe(true) - await handle.dispose() - }) - - it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => { - // `SubagentStop` recovers the child at `subagent/end`; a relative marker proves `runPoint` - // receives that agent and runs in the child's cwd rather than the executor default. - const serverDir = dir() - const childDir = dir() - const marker = join(childDir, 'stopwhere') - hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'pwd > stopwhere' }] }] }) - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) - // Executor default cwd = serverDir (deliberately NOT the child session cwd). - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) - await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) - ctx.llm.registerAdapter(['mock'], new MockAdapter([])) - - // Register a live child on its own session cwd; emit subagent/end with its id. - const { SessionId } = await import('@deepseek-ai/dsh-session') - const childHandle = await ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } }) - ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' }) - - await waitFor(() => existsSync(marker)) - expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir - const { readFileSync } = await import('node:fs') - const where = readFileSync(marker, 'utf8').trim() - // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. - expect(where.endsWith(childDir.split('/').pop()!)).toBe(true) - await childHandle.dispose() - }) -}) - -describe('hooks-claude coverage — systemMessage is warned, not surfaced', () => { - it('a hook emitting a systemMessage is logged as not-yet-surfaced', async () => { - const d = dir() - const s = sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n') - const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(path, adapter) - const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) - // Not surfaced: the systemMessage text never reaches the model request. - expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') - }) -}) - -describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait)', () => { - it('does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)', async () => { - // Session-start injection is detached, so an immediate prompt need not observe it. Assert only - // the guaranteed behavior—no crash and a completed turn—without pre-waiting away the race. - const d = dir() - const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n') - const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // Send immediately — do NOT wait for the session-start inject. - agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing - }) -}) diff --git a/packages/hooks/hooks-claude/tsconfig.json b/packages/hooks/hooks-claude/tsconfig.json index 909db9b5c3..07c88610f9 100644 --- a/packages/hooks/hooks-claude/tsconfig.json +++ b/packages/hooks/hooks-claude/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../core/session" }, + { + "path": "../../session-persistence/session-persistence" + }, { "path": "../../subagent/subagent" }, diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index 078bbc2e9d..0784857286 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -48,6 +48,8 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped 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. +Every agent-scoped stdin payload carries `session_id` and `transcript_path`. The bridge resolves the latter through `ctx.sessionPersistence.locate(session.header)` when available and otherwise sends `null`, preserving the Codex `string | null` shape. Lookup does not create or flush the artifact, so a path can be absent before the first turn-end checkpoint or omit the current open turn. + `SessionStart` — the one emit point — runs detached; each run chain is tracked, and disposing the bridge aborts a still-running hook process, then drains the continuation before the dispose resolves (`createDetachedRuns` in `dsh-hook-protocol`). ## Context source diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index fe667b0302..8c8686c539 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -29,18 +29,21 @@ "@deepseek-ai/dsh-hook-protocol": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index ad240e2e4d..8fa89a469d 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -17,6 +17,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-session-persistence' import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { appendHookInvoked, @@ -172,7 +173,7 @@ export function apply(ctx: Context, config: Config): void { // hook may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. ctx.on('agent/session-start', (agent, source) => { - detached.track(runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal }) + detached.track(runPoint('SessionStart', source, { ...base(ctx, agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) if (context) agent.inject(context.content, { source: context.source }) @@ -184,7 +185,7 @@ export function apply(ctx: Context, config: Config): void { // UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask. ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise => { const turn = lastTurn(agent) - const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true }) + const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(ctx, agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true }) /* jscpd:ignore-start */ if (merged.decision === 'deny') return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } // Context alone is not a veto: DELEGATE so a later prompt-submit listener can @@ -202,7 +203,7 @@ export function apply(ctx: Context, config: Config): void { // PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored). ctx.on('tools/pre-execute', async (exec, next): Promise => { const turn = lastTurn(exec.agent) - const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) /* jscpd:ignore-end */ if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } return next() @@ -212,7 +213,7 @@ export function apply(ctx: Context, config: Config): void { ctx.on('tools/post-execute', async (exec, result, next): Promise => { const turn = lastTurn(exec.agent) /* jscpd:ignore-start */ - const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, 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 ? { additionalContexts: [context] } : {} } @@ -236,7 +237,7 @@ export function apply(ctx: Context, config: Config): void { // avoid continuing the same turn indefinitely. It is always false here, so an // unconditionally blocking hook force-continues every step until it self-limits. ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise => { - const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn }) + const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn }) /* jscpd:ignore-end */ if (merged.decision === 'deny') { // A blocking Stop hook forces continuation; a block with no reason (exit 2, @@ -270,10 +271,12 @@ function blocksToText(content: ContentBlock[]): string { /* jscpd:ignore-end */ /** Base fields on every Codex payload (no turn_id). */ -function base(agent: Agent | undefined, event: string, model: string): Record { +function base(ctx: Context, agent: Agent | undefined, event: string, model: string): Record { return { session_id: agent?.session.header.id ?? '', - transcript_path: null, + transcript_path: agent === undefined + ? null + : ctx.get('sessionPersistence')?.locate(agent.session.header)?.path ?? null, cwd: agent?.session.header.cwd ?? process.cwd(), hook_event_name: event, model, @@ -282,8 +285,8 @@ function base(agent: Agent | undefined, event: string, model: string): Record { - return { ...base(agent, event, model), turn_id: String(lastTurn(agent)) } +function turnBase(ctx: Context, agent: Agent | undefined, event: string, model: string): Record { + return { ...base(ctx, agent, event, model), turn_id: String(lastTurn(agent)) } } /** Extract a `command` string from a tool call's parsed arguments, else ''. */ @@ -295,14 +298,14 @@ function commandOf(args: unknown): string { return '' } -function preToolPayload(exec: ToolExecution, model: string): Record { +function preToolPayload(ctx: Context, exec: ToolExecution, model: string): Record { // `tool_name` is the REAL tool name (matching the `exec.name` matcher subject); // a hardcoded constant would disagree with what the matcher tests and make a // config's tool matcher never fire. `tool_input` keeps Codex's `{ command }` // shape (its shell payload), derived from the call's `command` arg when present. - return { ...turnBase(exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId } + return { ...turnBase(ctx, exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId } } -function postToolPayload(exec: ToolExecution, result: ToolExecutionResult, model: string): Record { - return { ...turnBase(exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } +function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult, model: string): Record { + return { ...turnBase(ctx, exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } } diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index d4a5797b96..72e3f45184 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -4,12 +4,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -40,11 +39,7 @@ function writeHooks(dir: string, hooks: unknown): void { async function harness(dir: string, adapter: MockAdapter): Promise { const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' }) @@ -141,11 +136,7 @@ describe('hooks-codex bridge', () => { writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: deny }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) @@ -167,11 +158,7 @@ describe('hooks-codex bridge', () => { const slow = script(dir, 'slow.sh', `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`) writeHooks(dir, { SessionStart: [{ hooks: [{ type: 'command', command: slow }] }] }) const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts new file mode 100644 index 0000000000..2f40751876 --- /dev/null +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -0,0 +1,637 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { AgentId } from '@deepseek-ai/dsh-agent' +import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +const dirs: string[] = [] +afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) +function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hx-cov-')); dirs.push(d); return d } +function sh(d: string, name: string, body: string): string { + const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p +} +function hooks(d: string, h: unknown): string { + writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json') +} + +type HarnessOpts = { stderrSummaryMaxChars?: number; sessionRoot?: string } +async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { + return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) +} +function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +/** Poll until `predicate` holds or the deadline passes — robust to detached + * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ +async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { + const deadline = Date.now() + timeout + while (!predicate()) { + if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline') + await new Promise(r => setTimeout(r, interval)) + } +} + +export type CoverageGroup = 'prompt' | 'post-tool' | 'result-shape' | 'edge-paths' | 'payload' + +/** Register independently schedulable slices of the hooks-codex coverage matrix. */ +export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGroup[]): void { + const selected = new Set(typeof groups === 'string' ? [groups] : groups) + if (selected.has('prompt')) describe('hooks-codex coverage — prompt decision mapping', () => { + it('uses the persistence locator for transcript_path and null without one', async () => { + async function capture(sessionRoot?: string): Promise<{ payload: { transcript_path: string | null }; expected: string | undefined }> { + const d = dir() + const cap = join(d, 'payload') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'capture.sh', `#!/usr/bin/env bash\ncat > "${cap}"\n`) }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('transcript'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + return { + payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string | null }, + expected: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path, + } + } + + const located = await capture(dir()) + expect(located.payload.transcript_path).toBe(located.expected) + expect((await capture()).payload.transcript_path).toBeNull() + }, 15_000) // Two real agent/hook subprocess loops need loaded pre-push runner headroom. + + it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => { + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const adapter = new MockAdapter([textResponse('no')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(0) + const te = events(agent).findLast(e => e.type === 'turn/end') + expect(te?.type === 'turn/end' && te.data.reason.kind).toBe('rejected') + }) + + it('UserPromptSubmit additionalContext is injected; a no-op hook proceeds', async () => { + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"ctx-x"}}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x') + }) + + it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => { + // Context alone is not a veto: the bridge delegates with `next()` and folds its context, so a + // downstream policy listener can still block. + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('should not run')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(0) + expect(events(agent).some(e => e.type === 'user/message')).toBe(false) + const te = events(agent).findLast(e => e.type === 'turn/end') + expect(te?.type === 'turn/end' && te.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' }) + }) + + 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')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.on('agent/prompt-submit', async () => ({ + kind: 'allow' as const, + content: [{ type: 'text' as const, text: 'rewritten-prompt' }], + 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) + const req = JSON.stringify(adapter.requests[0]!.messages) + 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' }) + }) + }) + + if (selected.has('post-tool')) describe('hooks-codex coverage — post-tool and session context mapping', () => { + it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', 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, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) + 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') }] }] }) + 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: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const result = events(agent).find(e => e.type === 'tool/result') + expect(result?.type === 'tool/result' && result.data.isError).toBe(true) + expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) + 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('SessionStart additionalContext is injected for the first request', async () => { + const d = dir() + hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + await waitFor(() => events(agent).some(e => e.type === 'context/message' + && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx')))) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx') + }) + + it('PostToolUse block (exit 2) → isError feedback; default reason', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'p.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const r = events(agent).find(e => e.type === 'tool/result') + expect(r?.type === 'tool/result' && r.data.isError).toBe(true) + expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) + }) + + it('PostToolUse additionalContext (clean exit) is attached after the result', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"post-ctx"}}\'\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) + }) + }) + + if (selected.has('result-shape')) describe('hooks-codex coverage — hook result shape and configuration', () => { + it('PreToolUse for a tool call WITHOUT a command arg passes an empty command (commandOf non-object/missing arm)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pre.sh', '#!/usr/bin/env bash\ncat >/dev/null\nexit 0\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', {}), textResponse('done')]) // no command arg + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(ran).toBe(true) // clean-exit hook allows; commandOf returned '' + }) + + it('a clean exit-0 hook records exitCode 0 and omits stderrSummary', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) + expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false) + }) + + it('a long stderr is truncated in the hook/result summary', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) + expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis + }) + + it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => { + const d = dir() + hooks(d, {}) + for (const bad of [0, -5, 1.5, Number.NaN]) { + const adapter = new MockAdapter([]) + await expect(harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: bad })) + .rejects.toThrow(/hooks-codex: stderrSummaryMaxChars must be a positive integer/) + } + }) + + it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 }) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') + }) + + it('warns on a skipped async hook and a direct apply() (schema bypass) runs', async () => { + const d = dir() + const marker = join(d, 'ran') + hooks(d, { UserPromptSubmit: [{ hooks: [ + { type: 'command', command: 'bg.sh', async: true }, // skipped → warn + { type: 'command', command: sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) }, + ] }] }) + const warn = vi.fn() + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + ctx.logger.warn = warn as never + // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. + HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(existsSync(marker)).toBe(true) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook')) + }) + + it('a no-op clean hook proceeds (contextFrom empty → next)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(ran).toBe(true) + }) + + it('SessionStart with no additionalContext is a no-op (contextFrom empty)', async () => { + const d = dir() + // The hook touches a marker so we can wait for it to ACTUALLY FINISH before + // asserting absence — a completed turn alone would not prove the detached + // session-start hook ran, making the absence check a false pass. + const marker = join(d, 'ss-ran') + hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\ntouch "${marker}"\nexit 0\n`) }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + await waitFor(() => existsSync(marker)) // the clean no-output hook has finished + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(events(agent).some(e => e.type === 'context/message')).toBe(false) + }) + + it('a throwing SessionStart inject is contained (logged)', async () => { + const d = dir() + hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const warn = vi.fn(); ctx.logger.warn = warn as never + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.inject = (() => { throw new Error('inject boom') }) + await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed'))) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed')) + }) + }) + + if (selected.has('edge-paths')) describe('hooks-codex coverage — matching and no-agent edge paths', () => { + it('a clean PreToolUse with no decision allows the tool (no deny)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'ok.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(ran).toBe(true) + }) + + it('a non-matching regex matcher skips the hook (matchesMatcher false → continue)', async () => { + const d = dir() + // /^Edit$/ does not match the tool name "Bash" → the group is skipped. + hooks(d, { PreToolUse: [{ matcher: '^Edit$', hooks: [{ type: 'command', command: sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded + expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) + }) + + it('a {"continue":false} hook is RECORDED as "stop" but does not halt the run (TODO(hook-continue-false))', async () => { + // Honoring `continue:false` is deferred — the seams have no hard-halt + // primitive. Assert the LOG records the halt request AND that the run is not + // actually halted (the tool still runs, the turn completes). + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded + expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred) + }) + + it('PreToolUse deny with EMPTY stderr uses the default reason (?? right arm)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const r = events(agent).find(e => e.type === 'tool/result') + expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) + }) + + it('PostToolUse block AND additionalContext are surfaced together', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'bc.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"ctx too"}}\'\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const r = events(agent).find(e => e.type === 'tool/result') + expect(r?.type === 'tool/result' && r.data.isError).toBe(true) + expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) + expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true) + }) + + it('commandOf reads a non-string command arg as an empty command', async () => { + const d = dir() + // The tool-call arguments carry `command` as a NUMBER → commandOf's + // `typeof command === 'string'` false arm → '' (the payload's tool_input.command). + const cap = join(d, 'payload') + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } } + expect(payload.tool_input.command).toBe('') + }) + + it('a no-agent direct PreToolUse run uses process.cwd() and turn 0 (no session to record)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) + let ran = false + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) + const { CallId } = await import('@deepseek-ai/dsh-llm') + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) + expect(ran).toBe(false) // denied + expect(result.isError).toBe(true) + }) + + it('a no-agent direct PostToolUse run attaches context with no session to record', async () => { + const d = dir() + hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"x"}}\'\n') }] }] }) + const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + 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.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 () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.bash.run = (() => Promise.reject(new Error('executor down'))) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) + }) + }) + + if (selected.has('payload')) describe('hooks-codex coverage — continuation, payload, and cwd mapping', () => { + it('a blocking Stop hook with EMPTY stderr still forces continuation (no reason required)', async () => { + // Regression: an exit-2 Stop hook with no stderr yields decision 'deny' + + // reason undefined; the turn must STILL force-continue, not silently stop. + const d = dir() + const marker = join(d, 'fired') + hooks(d, { Stop: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) }] }] }) + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') + }) + + it('a clean UserPromptSubmit hook that prints PLAIN stdout injects it as context', async () => { + // Codex feeds a SessionStart/UserPromptSubmit hook's PLAIN (non-JSON) stdout + // as additionalContext (unlike CC, which needs a JSON hookSpecificOutput). + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho "extra guidance from a plain hook"\nexit 0\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook') + }) + + it('a NON-clean SessionStart hook (exit 2) does NOT inject its stdout as context', async () => { + // SessionStart cannot block, but non-clean stdout still must not become context. The marker + // waits for detached completion; `echo stale; exit 2` then proves the exit-code gate matches + // the codec's structured-stdout rule. + const d = dir() + const marker = join(d, 'ran') + hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + await waitFor(() => existsSync(marker)) // the exit-2 hook has finished + expect(events(agent).some(e => e.type === 'context/message' + && e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false) + }) + + it('a UserPromptSubmit hook with a non-blocking error exit (1) + stdout does NOT inject it', async () => { + // Exit 1 is a non-blocking error (no decision), so the prompt is NOT blocked + // and the handler falls through to the context path — the gate must still + // suppress the error hook's stdout ("stale" never reaches the model). + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'e.sh', '#!/usr/bin/env bash\necho "stale"\nexit 1\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran + expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale') + }) + + it('a clean SessionStart hook that prints PLAIN stdout injects it (not JSON)', async () => { + const d = dir() + hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'ss.sh', '#!/usr/bin/env bash\necho "session preamble"\nexit 0\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + await waitFor(() => events(agent).some(e => e.type === 'context/message' + && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble')))) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') + }) + + it('a clean hook that prints JSON is NOT injected as prose (plain-stdout gate)', async () => { + // A structured (JSON) stdout must go through the hookSpecificOutput path, not + // be dumped verbatim as context — the `!startsWith('{')` gate guards this. + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'j.sh', '#!/usr/bin/env bash\necho \'{"unrelated":"json"}\'\nexit 0\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated') + }) + + it('the PreToolUse payload carries the REAL tool name (matches the matcher subject)', async () => { + // Regression: the payload once hardcoded tool_name "Bash", disagreeing with + // the exec.name matcher subject — a config matcher on the real name would + // then never fire. Capture the payload and assert tool_name === the real name. + const d = dir() + const cap = join(d, 'payload') + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } } + expect(payload.tool_name).toBe('shell') + expect(payload.tool_input.command).toBe('ls') + }) + + it('a Codex matcher on the REAL tool name fires (matcher subject === payload tool_name)', async () => { + // A regex matcher matching the real tool name must select the hook — proving + // the matcher subject and the payload tool_name agree. + const d = dir() + hooks(d, { PreToolUse: [{ matcher: 'shell', hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + let ran = false + ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(ran).toBe(false) // the matcher fired → the hook denied the tool + expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true) + }) + + it('a hook emitting a systemMessage is warned as not-yet-surfaced', async () => { + const d = dir() + hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n') }] }] }) + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(join(d, 'hooks.json'), adapter) + const warn = vi.fn(); ctx.logger.warn = warn as never + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) + expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') + }) + + it('runs an agent-scoped hook in the session cwd, not the executor default', async () => { + // Same regression as the CC bridge: the Codex bridge must thread the session + // cwd as the hook workdir. Executor default = serverDir; session cwd = + // sessionDir; the PreToolUse hook's `pwd` marker must land in sessionDir. + const serverDir = dir() + const sessionDir = dir() + const marker = join(sessionDir, 'where') + hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) + await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' }) + ctx.llm.registerAdapter(['mock'], adapter) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const { SessionId } = await import('@deepseek-ai/dsh-session') + const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) + handle.agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, handle.agent as ReactLoopAgent) + expect(existsSync(marker)).toBe(true) + expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true) + await handle.dispose() + }) + }) +} diff --git a/packages/hooks/hooks-codex/tests/coverage-post-tool.spec.ts b/packages/hooks/hooks-codex/tests/coverage-post-tool.spec.ts new file mode 100644 index 0000000000..0cd39dbe20 --- /dev/null +++ b/packages/hooks/hooks-codex/tests/coverage-post-tool.spec.ts @@ -0,0 +1,3 @@ +import { defineCoverageCases } from './coverage-cases.ts' + +defineCoverageCases(['post-tool', 'payload']) diff --git a/packages/hooks/hooks-codex/tests/coverage-prompt.spec.ts b/packages/hooks/hooks-codex/tests/coverage-prompt.spec.ts new file mode 100644 index 0000000000..be18c719f6 --- /dev/null +++ b/packages/hooks/hooks-codex/tests/coverage-prompt.spec.ts @@ -0,0 +1,3 @@ +import { defineCoverageCases } from './coverage-cases.ts' + +defineCoverageCases(['prompt', 'edge-paths']) diff --git a/packages/hooks/hooks-codex/tests/coverage-result-shape.spec.ts b/packages/hooks/hooks-codex/tests/coverage-result-shape.spec.ts new file mode 100644 index 0000000000..9546872e3c --- /dev/null +++ b/packages/hooks/hooks-codex/tests/coverage-result-shape.spec.ts @@ -0,0 +1,3 @@ +import { defineCoverageCases } from './coverage-cases.ts' + +defineCoverageCases('result-shape') diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts deleted file mode 100644 index 71bf4b2d39..0000000000 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ /dev/null @@ -1,599 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' -import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' - -const dirs: string[] = [] -afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) -function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hx-cov-')); dirs.push(d); return d } -function sh(d: string, name: string, body: string): string { - const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p -} -function hooks(d: string, h: unknown): string { - writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json') -} - -async function harness(configPath: string, adapter: MockAdapter, opts: { stderrSummaryMaxChars?: number } = {}): Promise { - const ctx = new Context() - await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts }) - ctx.llm.registerAdapter(['mock'], adapter) - return ctx -} -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { - return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) -} -function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } -/** Poll until `predicate` holds or the deadline passes — robust to detached - * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ -async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { - const deadline = Date.now() + timeout - while (!predicate()) { - if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline') - await new Promise(r => setTimeout(r, interval)) - } -} - -describe('hooks-codex coverage — decision mapping paths', () => { - it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => { - const d = dir() - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) - const adapter = new MockAdapter([textResponse('no')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(0) - const te = events(agent).findLast(e => e.type === 'turn/end') - expect(te?.type === 'turn/end' && te.data.reason.kind).toBe('rejected') - }) - - it('UserPromptSubmit additionalContext is injected; a no-op hook proceeds', async () => { - const d = dir() - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"ctx-x"}}\'\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x') - }) - - it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => { - // Context alone is not a veto: the bridge delegates with `next()` and folds its context, so a - // downstream policy listener can still block. - const d = dir() - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] }) - const adapter = new MockAdapter([textResponse('should not run')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(0) - expect(events(agent).some(e => e.type === 'user/message')).toBe(false) - const te = events(agent).findLast(e => e.type === 'turn/end') - expect(te?.type === 'turn/end' && te.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' }) - }) - - 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')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.on('agent/prompt-submit', async () => ({ - kind: 'allow' as const, - content: [{ type: 'text' as const, text: 'rewritten-prompt' }], - 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) - const req = JSON.stringify(adapter.requests[0]!.messages) - 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 () => { - 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, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const result = events(agent).find(e => e.type === 'tool/result') - expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) - 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') }] }] }) - 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: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const result = events(agent).find(e => e.type === 'tool/result') - expect(result?.type === 'tool/result' && result.data.isError).toBe(true) - expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true) - 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('SessionStart additionalContext is injected for the first request', async () => { - const d = dir() - hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - await waitFor(() => events(agent).some(e => e.type === 'context/message' - && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx')))) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx') - }) - - it('PostToolUse block (exit 2) → isError feedback; default reason', async () => { - const d = dir() - hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'p.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const r = events(agent).find(e => e.type === 'tool/result') - expect(r?.type === 'tool/result' && r.data.isError).toBe(true) - expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true) - }) - - it('PostToolUse additionalContext (clean exit) is attached after the result', async () => { - const d = dir() - hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"post-ctx"}}\'\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) - }) - - it('PreToolUse for a tool call WITHOUT a command arg passes an empty command (commandOf non-object/missing arm)', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pre.sh', '#!/usr/bin/env bash\ncat >/dev/null\nexit 0\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', {}), textResponse('done')]) // no command arg - const ctx = await harness(join(d, 'hooks.json'), adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(ran).toBe(true) // clean-exit hook allows; commandOf returned '' - }) - - it('a clean exit-0 hook records exitCode 0 and omits stderrSummary', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) - expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false) - }) - - it('a long stderr is truncated in the hook/result summary', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) - expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis - }) - - it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => { - const d = dir() - hooks(d, {}) - for (const bad of [0, -5, 1.5, Number.NaN]) { - const adapter = new MockAdapter([]) - await expect(harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: bad })) - .rejects.toThrow(/hooks-codex: stderrSummaryMaxChars must be a positive integer/) - } - }) - - it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 }) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') - }) - - it('warns on a skipped async hook and a direct apply() (schema bypass) runs', async () => { - const d = dir() - const marker = join(d, 'ran') - hooks(d, { UserPromptSubmit: [{ hooks: [ - { type: 'command', command: 'bg.sh', async: true }, // skipped → warn - { type: 'command', command: sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) }, - ] }] }) - const warn = vi.fn() - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = new Context() - await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - ctx.logger.warn = warn as never - // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. - HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) - ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(existsSync(marker)).toBe(true) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook')) - }) - - it('a no-op clean hook proceeds (contextFrom empty → next)', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'n.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(ran).toBe(true) - }) - - it('SessionStart with no additionalContext is a no-op (contextFrom empty)', async () => { - const d = dir() - // The hook touches a marker so we can wait for it to ACTUALLY FINISH before - // asserting absence — a completed turn alone would not prove the detached - // session-start hook ran, making the absence check a false pass. - const marker = join(d, 'ss-ran') - hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\ntouch "${marker}"\nexit 0\n`) }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - await waitFor(() => existsSync(marker)) // the clean no-output hook has finished - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(events(agent).some(e => e.type === 'context/message')).toBe(false) - }) - - it('a throwing SessionStart inject is contained (logged)', async () => { - const d = dir() - hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.inject = (() => { throw new Error('inject boom') }) - await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed'))) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed')) - }) - - it('a clean PreToolUse with no decision allows the tool (no deny)', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'ok.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(ran).toBe(true) - }) - - it('a non-matching regex matcher skips the hook (matchesMatcher false → continue)', async () => { - const d = dir() - // /^Edit$/ does not match the tool name "Bash" → the group is skipped. - hooks(d, { PreToolUse: [{ matcher: '^Edit$', hooks: [{ type: 'command', command: sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded - expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) - }) - - it('a {"continue":false} hook is RECORDED as "stop" but does not halt the run (TODO(hook-continue-false))', async () => { - // Honoring `continue:false` is deferred — the seams have no hard-halt - // primitive. Assert the LOG records the halt request AND that the run is not - // actually halted (the tool still runs, the turn completes). - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded - expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred) - }) - - it('PreToolUse deny with EMPTY stderr uses the default reason (?? right arm)', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const r = events(agent).find(e => e.type === 'tool/result') - expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) - }) - - it('PostToolUse block AND additionalContext are surfaced together', async () => { - const d = dir() - hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'bc.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"ctx too"}}\'\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const r = events(agent).find(e => e.type === 'tool/result') - expect(r?.type === 'tool/result' && r.data.isError).toBe(true) - expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true) - expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('ctx too')))).toBe(true) - }) - - it('commandOf reads a non-string command arg as an empty command', async () => { - const d = dir() - // The tool-call arguments carry `command` as a NUMBER → commandOf's - // `typeof command === 'string'` false arm → '' (the payload's tool_input.command). - const cap = join(d, 'payload') - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } } - expect(payload.tool_input.command).toBe('') - }) - - it('a no-agent direct PreToolUse run uses process.cwd() and turn 0 (no session to record)', async () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) - const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) - let ran = false - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) - const { CallId } = await import('@deepseek-ai/dsh-llm') - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) - expect(ran).toBe(false) // denied - expect(result.isError).toBe(true) - }) - - it('a no-agent direct PostToolUse run attaches context with no session to record', async () => { - const d = dir() - hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'pc.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"x"}}\'\n') }] }] }) - const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - 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.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 () => { - const d = dir() - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.bash.run = (() => Promise.reject(new Error('executor down'))) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const res = events(agent).find(e => e.type === 'hook/result') - expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) - }) - - it('a blocking Stop hook with EMPTY stderr still forces continuation (no reason required)', async () => { - // Regression: an exit-2 Stop hook with no stderr yields decision 'deny' + - // reason undefined; the turn must STILL force-continue, not silently stop. - const d = dir() - const marker = join(d, 'fired') - hooks(d, { Stop: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) }] }] }) - const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation - expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') - }) - - it('a clean UserPromptSubmit hook that prints PLAIN stdout injects it as context', async () => { - // Codex feeds a SessionStart/UserPromptSubmit hook's PLAIN (non-JSON) stdout - // as additionalContext (unlike CC, which needs a JSON hookSpecificOutput). - const d = dir() - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho "extra guidance from a plain hook"\nexit 0\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook') - }) - - it('a NON-clean SessionStart hook (exit 2) does NOT inject its stdout as context', async () => { - // SessionStart cannot block, but non-clean stdout still must not become context. The marker - // waits for detached completion; `echo stale; exit 2` then proves the exit-code gate matches - // the codec's structured-stdout rule. - const d = dir() - const marker = join(d, 'ran') - hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - await waitFor(() => existsSync(marker)) // the exit-2 hook has finished - expect(events(agent).some(e => e.type === 'context/message' - && e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false) - }) - - it('a UserPromptSubmit hook with a non-blocking error exit (1) + stdout does NOT inject it', async () => { - // Exit 1 is a non-blocking error (no decision), so the prompt is NOT blocked - // and the handler falls through to the context path — the gate must still - // suppress the error hook's stdout ("stale" never reaches the model). - const d = dir() - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'e.sh', '#!/usr/bin/env bash\necho "stale"\nexit 1\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran - expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale') - }) - - it('a clean SessionStart hook that prints PLAIN stdout injects it (not JSON)', async () => { - const d = dir() - hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'ss.sh', '#!/usr/bin/env bash\necho "session preamble"\nexit 0\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - await waitFor(() => events(agent).some(e => e.type === 'context/message' - && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble')))) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') - }) - - it('a clean hook that prints JSON is NOT injected as prose (plain-stdout gate)', async () => { - // A structured (JSON) stdout must go through the hookSpecificOutput path, not - // be dumped verbatim as context — the `!startsWith('{')` gate guards this. - const d = dir() - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'j.sh', '#!/usr/bin/env bash\necho \'{"unrelated":"json"}\'\nexit 0\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated') - }) - - it('the PreToolUse payload carries the REAL tool name (matches the matcher subject)', async () => { - // Regression: the payload once hardcoded tool_name "Bash", disagreeing with - // the exec.name matcher subject — a config matcher on the real name would - // then never fire. Capture the payload and assert tool_name === the real name. - const d = dir() - const cap = join(d, 'payload') - hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'cap.sh', `#!/usr/bin/env bash\ncat > "${cap}"\nexit 0\n`) }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } } - expect(payload.tool_name).toBe('shell') - expect(payload.tool_input.command).toBe('ls') - }) - - it('a Codex matcher on the REAL tool name fires (matcher subject === payload tool_name)', async () => { - // A regex matcher matching the real tool name must select the hook — proving - // the matcher subject and the payload tool_name agree. - const d = dir() - hooks(d, { PreToolUse: [{ matcher: 'shell', hooks: [{ type: 'command', command: sh(d, 'd.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - let ran = false - ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(ran).toBe(false) // the matcher fired → the hook denied the tool - expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true) - }) - - it('a hook emitting a systemMessage is warned as not-yet-surfaced', async () => { - const d = dir() - hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n') }] }] }) - const adapter = new MockAdapter([textResponse('ok')]) - const ctx = await harness(join(d, 'hooks.json'), adapter) - const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) - expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') - }) - - it('runs an agent-scoped hook in the session cwd, not the executor default', async () => { - // Same regression as the CC bridge: the Codex bridge must thread the session - // cwd as the hook workdir. Executor default = serverDir; session cwd = - // sessionDir; the PreToolUse hook's `pwd` marker must land in sessionDir. - const serverDir = dir() - const sessionDir = dir() - const marker = join(sessionDir, 'where') - hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] }) - const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) - const ctx = new Context() - await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) - await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' }) - ctx.llm.registerAdapter(['mock'], adapter) - ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) - handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) - expect(existsSync(marker)).toBe(true) - expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true) - await handle.dispose() - }) -}) diff --git a/packages/hooks/hooks-codex/tsconfig.json b/packages/hooks/hooks-codex/tsconfig.json index f936b500aa..ae3c91e9dd 100644 --- a/packages/hooks/hooks-codex/tsconfig.json +++ b/packages/hooks/hooks-codex/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../core/session" }, + { + "path": "../../session-persistence/session-persistence" + }, { "path": "../../llm/llm" }, diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 343fb4a70a..43f4443107 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -19,6 +19,8 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence |---|---|---| | `root` | `string` (required) | Root directory for all session files. **No default** — a `process.cwd()` default would scatter files as the process's cwd changes (bash calls, subprocesses). | +`locate(meta)` returns `{ kind: 'jsonl', path }` using the resolved absolute root and the same cwd-bucket/id encoding as materialization. It performs no filesystem I/O: the target can be returned before the file exists, and an existing file contains only the last flushed prefix. + ## Durability and crash semantics - **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory. A created-but-never-appended session leaves nothing on disk and is absent from `list`. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 1d13ff424e..0c8a2ee3ef 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -1,7 +1,8 @@ /** * JSONL durable session-persistence backend. It stores a header and contiguous * events in one append-only file per session, and delegates orchestration to - * {@link PersistenceCoordinator}. + * {@link PersistenceCoordinator}. Its side-effect-free locator returns the + * absolute per-session log target before materialization. * @module @deepseek-ai/dsh-session-persistence-jsonl */ @@ -12,7 +13,7 @@ import { dirname, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { SessionPersistence, PersistenceCoordinator, - type PersistenceBackend, type StoredPrefix, + type PersistenceBackend, type SessionLocation, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -68,6 +69,11 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi /* jscpd:ignore-start */ // --- SessionPersistence service surface (delegated to the coordinator) --- + /** Resolve the absolute target path without touching the filesystem. */ + locate(meta: SessionHeader): SessionLocation { + return { kind: 'jsonl', path: logPath(this.root, meta.cwd, meta.id) } + } + create(meta: SessionHeader): Promise { return this.coordinator.create(meta) } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 75fcb48732..3c76616796 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { isAbsolute, join, relative, resolve } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -112,6 +112,19 @@ describe('SessionPersistenceJsonl: format helpers', () => { it('encodeSegment rejects an empty id', () => { expect(() => encodeSegment('')).toThrow(/empty/) }) + + it('resolves a relative custom root before locating a session', async () => { + const absoluteRoot = await freshRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: relative(process.cwd(), absoluteRoot) }) + const m = meta('relative-location', '/work') + expect(ctx.sessionPersistence.locate(m)).toEqual({ + kind: 'jsonl', + path: logPath(resolve(absoluteRoot), '/work', m.id), + }) + await fiber.dispose() + }) }) describe('SessionPersistenceJsonl: durability and crash semantics', () => { @@ -126,8 +139,13 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { it('lazy materialization: create() writes no file until the first append', async () => { const m = meta('lazy', '/work') + const location = ctx.sessionPersistence.locate(m) + expect(location).toEqual({ kind: 'jsonl', path: logPath(root, '/work', m.id) }) + expect(isAbsolute(location!.path)).toBe(true) + await ctx.sessionPersistence.create(m) - // nothing on disk yet + // locate() is a pure target-path calculation: neither it nor create() + // materializes a file before the first append. const dir = sessionDir(root, '/work') await expect(stat(logPath(root, '/work', m.id))).rejects.toThrow() expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id) @@ -139,6 +157,26 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { void dir }) + it('keeps the same location on resume and gives a fork its own location', async () => { + const parent = meta('location-parent', '/work') + const parentLocation = ctx.sessionPersistence.locate(parent) + await ctx.sessionPersistence.create(parent) + await ctx.sessionPersistence.append(parent.id, oneTurnLog()) + + const loaded = await ctx.sessionPersistence.load(parent.id) + expect(ctx.sessionPersistence.locate(loaded.meta)).toEqual(parentLocation) + + const child = { + ...loaded.meta, + id: SessionId('location-child'), + parentSession: parent.id, + seedLength: loaded.events.length, + } + const childLocation = ctx.sessionPersistence.locate(child) + expect(childLocation?.path).not.toBe(parentLocation?.path) + expect(childLocation).toEqual({ kind: 'jsonl', path: logPath(root, '/work', child.id) }) + }) + it('round-trip is byte-identical (incl. assistant/chunk verbatim)', async () => { const m = meta('chunks') const log: SessionEvent[] = [ diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 1411e177be..241e7ceb38 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -2,6 +2,8 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` implementation ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), built to validate that the abstract seam and the shared `runPersistenceContract` suite are genuinely backend-agnostic. It satisfies the SAME contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes. +`locate(meta)` returns `undefined`: all sessions share one database, so there is no honest independent per-session transcript path. + > **TODO:** this backend talks to `node:sqlite` directly. If a cordis database service (`cordis/db` / a `@cordisjs` SQL driver plugin) is adopted, route through that instead of holding a raw `DatabaseSync` here — the contract surface (`SessionPersistence`) would not change, only the storage driver. ## Storage model diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 7d23292e9d..d886124961 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -1,7 +1,8 @@ /** * SQLite durable session-persistence backend. It maps each session header and * event to rows, and delegates write-path orchestration to - * {@link PersistenceCoordinator}. + * {@link PersistenceCoordinator}. It has no independent per-session artifact, + * so its locator returns `undefined`. * @module @deepseek-ai/dsh-session-persistence-sqlite */ @@ -12,7 +13,7 @@ import { mkdir } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { SessionPersistence, PersistenceCoordinator, - type PersistenceBackend, type StoredPrefix, + type PersistenceBackend, type SessionLocation, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -95,6 +96,11 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers // --- SessionPersistence service surface (delegated to the coordinator) --- + /** SQLite has one database, not an independent local artifact per session. */ + locate(_meta: SessionHeader): SessionLocation | undefined { + return undefined + } + create(meta: SessionHeader): Promise { return this.coordinator.create(meta) } diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index bae79b0a53..81316b9d2a 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -153,6 +153,12 @@ describe('scanRows', () => { }) describe('SessionPersistenceSqlite: durability and crash semantics', () => { + it('has no independent per-session log location', async () => { + const { ctx, dispose } = await backend() + expect(ctx.sessionPersistence.locate(meta('sqlite-location'))).toBeUndefined() + await dispose() + }) + it('an interrupted turn (rows after the last turn/end) is PRESERVED and closed during load', async () => { const path = await freshDbPath() const m = meta('crash') diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index f09a21251b..a294575fd9 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -8,6 +8,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | Method | Contract | |---|---| +| `locate(meta): SessionLocation \| undefined` | Resolve an absolute per-session artifact target without I/O or materialization. Backends without an independent local artifact return `undefined`. | | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | @@ -24,6 +25,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l `PersistenceCoordinator` owns per-id state, write-behind buffers and serialization, the `session/event` → `session/flush` drain, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its four public service methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). +The side-effect-free `locate` query remains backend-owned because it describes storage topology rather than write orchestration. + The `PersistenceBackend` hooks (the only seam between the coordinator and storage): | Hook | Role | @@ -44,9 +47,9 @@ Import `runPersistenceContract` from `tests/contract.ts` (the public-API contrac Three backends run these suites: an in-memory reference (in `tests/`), `dsh-session-persistence-jsonl` (append-only file log) and `dsh-session-persistence-sqlite` (`node:sqlite`, each `SessionEvent` one row `(session_id, seq, type, time, data, source_event_seqs, surface_op)`). All passing the same contract + coordinator suite is the proof that the seam is genuinely backend-agnostic — lazy materialization, crash-tail-on-load, and contiguous-seq hold identically over file bytes and over a transactional store. -## Metadata types +## Metadata and location types -Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`, `seedLength?`). +Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`, `seedLength?`). `SessionLocation` is `{ readonly kind: string; readonly path: string }`; its path is an absolute backend target, not proof that the artifact exists or contains an unflushed turn. ## Model Experience diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 3e6c9c21ed..3c102e6ede 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -21,6 +21,18 @@ declare module 'cordis' { } } +/** + * A backend-resolved, per-session local artifact location. The path is an + * absolute target path and can name an artifact that has not materialized yet. + * Consumers must treat it as a location hint, never as an authorization token. + */ +export interface SessionLocation { + /** Backend-specific artifact kind, for example `jsonl`. */ + readonly kind: string + /** Absolute path to this session's backend-owned artifact. */ + readonly path: string +} + /** * Durable append-only session storage. Implementations preserve contiguous, * losslessly JSON-serializable events; {@link append} resolves only after @@ -32,6 +44,15 @@ export abstract class SessionPersistence extends Service { super(ctx, 'sessionPersistence') } + /** + * Resolve this backend's independent local artifact for a session without + * reading, creating, flushing, or otherwise materializing it. Backends such + * as SQLite that do not own one artifact per session return `undefined`. + * @param meta - the immutable session header whose artifact is requested. + * @returns the backend-specific absolute location, when one exists. + */ + abstract locate(meta: SessionHeader): SessionLocation | undefined + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index a937a39da7..5a0ec2d3c3 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -41,6 +41,10 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend // --- service surface (delegated to the coordinator) --- + locate(_meta: SessionHeader): undefined { + return undefined + } + create(m: SessionHeader): Promise { return this.coordinator.create(m) } 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..733b746a6a 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -34,6 +34,10 @@ class TestPersistence extends SessionPersistence { this.afterList = undefined } + locate(_meta: SessionHeader): undefined { + return undefined + } + create(meta: SessionHeader): Promise { TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] }) return Promise.resolve() @@ -110,7 +114,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 +231,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..d897a5bf67 --- /dev/null +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -0,0 +1,426 @@ +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 + } + + locate(_meta: SessionHeader): undefined { + return 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/README.md b/packages/skill/skill-local/README.md index 9449a40ec5..8034441272 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -12,7 +12,7 @@ Requires `ctx.skills` (`inject: ['skills']`). | Field | Default | Meaning | |---|---|---| -| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root; scans `skills` under this directory. | +| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root resolved by [`@deepseek-ai/dsh-home`](../../util/home/README.md); scans `skills` under this directory. | | `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. | | `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. | diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json index d1ca775a26..d490438c51 100644 --- a/packages/skill/skill-local/package.json +++ b/packages/skill/skill-local/package.json @@ -23,6 +23,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-home": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -32,6 +33,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 19a15f1de8..ee109fbb16 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -17,6 +17,7 @@ import z from 'schemastery' import type Schema from 'schemastery' import { parse as parseYaml } from 'yaml' import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs' +import { resolveDshHome } from '@deepseek-ai/dsh-home' import { isSkillName, type SkillCandidate, @@ -92,7 +93,7 @@ export class LocalSkillProvider implements SkillProvider { private readonly customSkillDirs: string[] constructor(private readonly ctx: Context, config: Config = {}) { - this.dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')) + this.dshHome = resolveDshHome(config.dshHome) this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')) this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) } diff --git a/packages/skill/skill-local/tsconfig.json b/packages/skill/skill-local/tsconfig.json index 018f0a4a50..f51147abce 100644 --- a/packages/skill/skill-local/tsconfig.json +++ b/packages/skill/skill-local/tsconfig.json @@ -9,6 +9,7 @@ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, + { "path": "../../util/home" }, { "path": "../../fs/fs" }, { "path": "../skill" } ] 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/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index 8794884518..63f548d217 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -34,14 +34,13 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", "@cordisjs/plugin-loader": "^1.0.0-rc.5", "cordis": "^4.0.0-rc.7" } diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index 8060a77fd4..110c4f83e4 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import * as Spawn from '@deepseek-ai/dsh-subagent-spawn' @@ -26,11 +23,7 @@ function start(ctx: Context, provider: string, request: Omit Promise.resolve({ logs: [] })), } as never) } - await ctx.plugin(AgentRegistry) await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 0005246523..e7d856e112 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -15,11 +12,7 @@ type Script = ConstructorParameters[0] async function setup(script: Script) { const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index f2500a4a56..1a986e69aa 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -32,6 +32,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -39,10 +40,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", "@cordisjs/plugin-loader": "^1.0.0-rc.5", "cordis": "^4.0.0-rc.7" } diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index e4ee2e6ab7..bb0b49fbcb 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -1,10 +1,7 @@ import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' 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' @@ -21,15 +18,13 @@ import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' */ export async function spawnHarness(workdir: string): Promise { const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) // This harness installs only the global default persona, so both parent and // spawned children render it. It stays neutral for both roles; the // delegation nudge lives in the e2e's user prompt and the subagent tool's // own description. - await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent. Report only when the requested work is done.' }) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx, { + systemPrompt: { persona: 'You are a coding agent. Report only when the requested work is done.' }, + }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 43ec8ee1ac..3862f110c5 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -1,13 +1,10 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -26,11 +23,7 @@ type Script = ConstructorParameters[0] async function setup(script: Script) { const ctx = new Context() const adapter = new MockAdapter(script) - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) @@ -303,11 +296,7 @@ describe('dsh-subagent-spawn', () => { // Rebuild the stack by hand so we hold the backend's fiber. const ctx = new Context() const adapter = new MockAdapter(['hang']) - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) @@ -336,11 +325,7 @@ describe('dsh-subagent-spawn', () => { it('a start racing an already-unloading backend cannot begin child creation', async () => { const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) diff --git a/packages/support/README.md b/packages/support/README.md index d1bb1883ed..a85fffcdac 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -5,9 +5,10 @@ Packages that exist to serve development, testing, and the examples rather than | Package | Role | ctx key | |---|---|---| | `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) | +| `agent-loop-testkit/` | Shared prerequisite mounting for tests that exercise the concrete agent loop | (library — imported by AgentLoop integration tests) | | `invariants/` | Runtime event-contract assertions for development diagnostics | (listens on `session/*`, `agent/*`) | | `loader-smoke/` | Shared real-Loader subprocess harness for keyless example smokes | (library — imported by example e2e suites) | | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | | `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) | -`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. +`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 03254a17a0..55e9aadf4d 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -4,7 +4,7 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie Three layers, importable separately: -- **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). +- **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). Startup failures preserve captured agent stderr in the rejected diagnostic. - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.golden.md` plus `tool-schemas.golden.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Must be called at vitest collection time. diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 3bae0ba279..c26cb84299 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 } : {}, @@ -281,6 +285,10 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // Harvest EVERY persisted log (parent + any subagent children) while the // temp dirs still exist, ordered primary-first. sessionLogs = await harvestSessionLogs(sessionsRoot) + } catch (error: unknown) { + const stderr = stderrChunks.join('') + if (stderr === '') throw error + throw new Error(`snapshot-harness: scenario failed: ${String(error)}\nagent stderr:\n${stderr}`, { cause: error }) } finally { // Failure-safe teardown: kill a still-running child and drop the temp dirs // even if seeding/spawn/a step/harvest threw, so a flaky run never leaks a @@ -291,6 +299,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/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index dc1675eac0..7bd457b7b4 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -3,6 +3,8 @@ * compares normalized stdout; comparable session fixtures are both replay input and expected * output. Record mode refreshes reproducible model scenarios from the live API, while refresh * mode replays committed scripts and rewrites derived artifacts without a key. + * Replay scenarios run concurrently because each subprocess owns unique temp cwd and persistence + * roots and only reads committed fixtures. Record and refresh scenarios stay serial while writing. * * Exactly one scenario per header-composition class pins the system prompt and tool schemas in * dedicated sidecars. Every live header is checked against that pin, so session-dependent @@ -461,7 +463,7 @@ export function stabilizeRefreshLog(fresh: string, existing: string, replacement } /** - * Register the suite: one `describe` per scenario (the golden/log compares and + * Register the suite: one test per scenario (the golden/log compares and * the header-uniformity guard) plus the fixture guard block (no orphan * scenario dirs, required files present, exactly one pin per header class, * pinning fixtures well-formed, every JSONL prompt-scrubbed, non-pinning @@ -477,6 +479,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const RECORDING = mode === 'record' const REFRESHING = mode === 'refresh' const childMode: 'replay' | 'record' = RECORDING ? 'record' : 'replay' + const scenarioSuite = mode === 'replay' ? describe.concurrent : describe /** The class a scenario's header composition belongs to (see {@link Scenario.headerClass}). */ const classOf = (scenario: Scenario): string => scenario.headerClass ?? 'default' @@ -496,11 +499,11 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { } } - for (const scenario of scenarios) { - describe(`snapshot: ${scenario.name}`, () => { + scenarioSuite('snapshot scenarios', () => { + for (const scenario of scenarios) { // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones // (sidecar-driven errors/cancel) are never re-recorded. - it.skipIf(RECORDING && !scenario.recorded)('matches the goldens', async () => { + it.skipIf(RECORDING && !scenario.recorded)(`snapshot: ${scenario.name} matches the goldens`, async ({ expect }) => { const dir = join(snapshotsDir, scenario.name) const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript const overrideFile = join(dir, 'replay.override.json') @@ -658,8 +661,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { } } }) - }) - } + } + }) describe('snapshot fixtures', () => { it('every scenario directory is registered (no orphans)', async () => { diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index d5fcd75a93..fccd1d6fcc 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -24,6 +24,8 @@ interface ScriptedLog { /** The whole scripted behavior for one run. Every field defaults to the least surprising choice. */ interface Behavior { + /** Exit during startup after writing any configured stderr note. */ + failOnBoot?: boolean /** Reject every `session/new` (exercises the expect-error step without extra dirs). */ rejectNewSession?: boolean /** Reject `session/new` only when `additionalDirectories` is non-empty (the real bridge's rule). */ @@ -62,6 +64,7 @@ const behavior: Behavior = fixtureFile === '' : JSON.parse(readFileSync(join(dirname(fixtureFile), 'behavior.json'), 'utf8')) as Behavior if (behavior.stderrNote !== undefined) process.stderr.write(`${behavior.stderrNote}\n`) +if (behavior.failOnBoot === true) process.exit(7) let nextOutboundId = 1000 let sessionId = '' diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 01b93dc81e..42ccbd7de4 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -38,6 +38,14 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }] describe('runScenario', () => { + it('includes agent stderr when the ACP connection closes during startup', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ failOnBoot: true, stderrNote: 'fake agent requested startup failure' }) + await expect(runScenario( + { steps: [{ op: 'initialize' }] }, + { agent: AGENT, mode: 'replay', fixtureFile }, + )).rejects.toThrow(/agent stderr:\nfake agent requested startup failure/) + }) + it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ permissionProbe: true, 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/agent-loop-testkit/README.md b/packages/support/agent-loop-testkit/README.md new file mode 100644 index 0000000000..350a8643e1 --- /dev/null +++ b/packages/support/agent-loop-testkit/README.md @@ -0,0 +1,27 @@ +# `@deepseek-ai/dsh-agent-loop-testkit` + +Shared prerequisite mounting for tests that exercise the concrete `AgentLoop`. `mountAgentLoopTestDependencies(ctx, options?)` installs the LLM, session, system-prompt, tool, and agent services in dependency order, then returns before the loop is mounted. + +The caller registers adapters and optional plugins, mounts `AgentLoop` with the configuration under test, and disposes its own Context. System-prompt and tool-registry configuration can be forwarded through `options`; the helper does not provide test defaults beyond those owned by the services. A plugin-load failure rejects the helper call, while services activated earlier in the sequence remain owned by the caller's Context. + +```ts +import { Context } from 'cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' + +const ctx = new Context() + +await mountAgentLoopTestDependencies(ctx) +// Register the test adapter and any optional plugins here. +await ctx.plugin(AgentLoop, { agents: [] }) +``` + +Tests of injection failures, partial topology, service load order, or service teardown mount their dependencies directly instead of using this helper. + +## Model Experience + +None, as this test-only composition helper neither drives nor modifies model requests. + +## Known Limitations and Deferred Work + +- **Only the mandatory prerequisite spine is shared** — adapters, optional plugins, `AgentLoop`, agents, and Context teardown remain caller-owned so scenario-specific ordering stays visible. diff --git a/packages/support/agent-loop-testkit/package.json b/packages/support/agent-loop-testkit/package.json new file mode 100644 index 0000000000..423bd3e80d --- /dev/null +++ b/packages/support/agent-loop-testkit/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-agent-loop-testkit", + "description": "Shared prerequisite mounting for tests that exercise the concrete agent loop", + "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-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/support/agent-loop-testkit/src/index.ts b/packages/support/agent-loop-testkit/src/index.ts new file mode 100644 index 0000000000..c7b0cb7304 --- /dev/null +++ b/packages/support/agent-loop-testkit/src/index.ts @@ -0,0 +1,46 @@ +/** + * Shared mounting for the services required before tests load the concrete + * agent loop. The caller retains ownership of the context, loop, adapters, + * optional plugins, and teardown. + * @module @deepseek-ai/dsh-agent-loop-testkit + */ + +import type { Context } from 'cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import type { Config as ToolRegistryConfig } from '@deepseek-ai/dsh-tools' + +/** Configuration forwarded to the prerequisite service plugins. */ +export interface AgentLoopTestDependenciesOptions { + /** Configuration for the system-prompt registry. */ + readonly systemPrompt?: SystemPromptConfig + /** Configuration for the tool registry. */ + readonly tools?: ToolRegistryConfig +} + +/** + * Mount the standard prerequisite services for an AgentLoop test. + * + * The function deliberately does not mount AgentLoop or register an adapter, + * so tests retain control of load order and the topology under test. The + * context owns every mounted service and remains responsible for disposal. A + * plugin-load failure rejects the promise; services activated earlier in the + * sequence remain context-owned and unwind with that context. + * @param ctx - test context that owns the mounted services. + * @param options - optional service configuration forwarded without mutation. + * @returns after every prerequisite service has activated. + */ +export async function mountAgentLoopTestDependencies( + ctx: Context, + options: AgentLoopTestDependenciesOptions = {}, +): Promise { + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, options.systemPrompt ?? {}) + await ctx.plugin(ToolRegistry, options.tools ?? {}) + await ctx.plugin(AgentRegistry) +} diff --git a/packages/support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts b/packages/support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts new file mode 100644 index 0000000000..aa125b561f --- /dev/null +++ b/packages/support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import { mountAgentLoopTestDependencies } from '../src/index.ts' + +describe('dsh-agent-loop-testkit', () => { + it('mounts a configurable prerequisite spine that can activate AgentLoop', async () => { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx, { + systemPrompt: { persona: 'Test persona.' }, + tools: { mode: 'native' }, + }) + + expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain('Test persona.') + await expect(ctx.plugin(AgentLoop, { agents: [] })).resolves.toBeDefined() + + await ctx.fiber.dispose() + }) +}) diff --git a/packages/support/agent-loop-testkit/tsconfig.json b/packages/support/agent-loop-testkit/tsconfig.json new file mode 100644 index 0000000000..5e5b3c47f2 --- /dev/null +++ b/packages/support/agent-loop-testkit/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/tools" + } + ] +} 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/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 9ff69d7c76..bab0f1230c 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 739367a699..c13a0f76f8 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -1,12 +1,9 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' import type { SessionEvent } 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 { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -18,11 +15,7 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent */ async function harness(adapter: MockAdapter): Promise { const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(ToolTodo) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index 71efc51a07..0a28c4f6da 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -42,6 +42,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index 440120943f..f8e2e37627 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -5,13 +5,10 @@ */ import { Context } from 'cordis' -import LlmService, { CallId, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { CallId, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -188,11 +185,9 @@ export async function makeBridgeHarness(options: { const adapter = new MockAdapter(options.script ?? []) const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: options.persona ?? '' }) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx, { + systemPrompt: { persona: options.persona ?? '' }, + }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir }) await ctx.plugin(UserInteractionService) diff --git a/packages/util/README.md b/packages/util/README.md index 57ee38fa1a..4b4f23ddf7 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -5,9 +5,15 @@ 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) | +| `home/` | Canonical `DSH_HOME` resolution from explicit config, environment, or `~/.dsh` (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-home` gives every package the same configurable Harness home without assigning that cross-cutting fact to bash, skills, or a composition bundle. It resolves an explicit value before `$DSH_HOME`, falls back to `~/.dsh`, and returns an absolute path without caching, creating, or mutating anything. + `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/home/README.md b/packages/util/home/README.md new file mode 100644 index 0000000000..f9107a8706 --- /dev/null +++ b/packages/util/home/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-home + +`@deepseek-ai/dsh-home` is the single owner of DeepSeek Harness home-directory resolution. `resolveDshHome(configured?)` returns an absolute path using this precedence: + +1. The explicit `configured` path. +2. The `DSH_HOME` environment variable. +3. The `.dsh` directory under the current user's home directory. + +The resolver reads its inputs at call time. It does not cache a result, create the directory, or mutate `process.env`; consumers keep ownership of their own configuration fields and pass the configured value when resolving the shared home. + +## Model Experience + +Indirectly, through `dsh-tool-bash`, which exposes the resolved path to model bash as `DSH_HOME` without adding a prompt section. + +## Known Limitations and Deferred Work + +- **Resolution only** — the resolver makes a path absolute but does not create it, check access, or canonicalize symlinks; each consumer owns those filesystem decisions. diff --git a/packages/util/home/package.json b/packages/util/home/package.json new file mode 100644 index 0000000000..efeaf4832c --- /dev/null +++ b/packages/util/home/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-home", + "description": "Canonical DeepSeek Harness home-directory resolver", + "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/home/src/index.ts b/packages/util/home/src/index.ts new file mode 100644 index 0000000000..4e3d56b54b --- /dev/null +++ b/packages/util/home/src/index.ts @@ -0,0 +1,23 @@ +/** + * Canonical DeepSeek Harness home-directory resolution. + * + * @module @deepseek-ai/dsh-home + */ + +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' + +const DEFAULT_DSH_HOME_DIRNAME = '.dsh' + +/** Environment variable that overrides the default Harness home directory. */ +export const DSH_HOME_ENV = 'DSH_HOME' as const + +/** + * Resolve the DeepSeek Harness home directory without caching or mutating the environment. + * + * @param configured - Optional configured path, which takes precedence over the environment. + * @returns The absolute configured path, `$DSH_HOME`, or `~/.dsh`, in that order. + */ +export function resolveDshHome(configured?: string): string { + return resolve(configured ?? process.env[DSH_HOME_ENV] ?? join(homedir(), DEFAULT_DSH_HOME_DIRNAME)) +} diff --git a/packages/util/home/tests/home.spec.ts b/packages/util/home/tests/home.spec.ts new file mode 100644 index 0000000000..3ebde50bee --- /dev/null +++ b/packages/util/home/tests/home.spec.ts @@ -0,0 +1,26 @@ +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home' + +afterEach(() => vi.unstubAllEnvs()) + +describe('resolveDshHome', () => { + it('prefers an explicit configured path and resolves it absolutely', () => { + vi.stubEnv(DSH_HOME_ENV, './environment-home') + + expect(resolveDshHome('./configured-home')).toBe(resolve('./configured-home')) + }) + + it('uses DSH_HOME when no configured path is supplied', () => { + vi.stubEnv(DSH_HOME_ENV, './environment-home') + + expect(resolveDshHome()).toBe(resolve('./environment-home')) + }) + + it('defaults to the .dsh directory under the user home', () => { + vi.stubEnv(DSH_HOME_ENV, undefined) + + expect(resolveDshHome()).toBe(join(homedir(), '.dsh')) + }) +}) diff --git a/packages/util/home/tsconfig.json b/packages/util/home/tsconfig.json new file mode 100644 index 0000000000..9770ef25d6 --- /dev/null +++ b/packages/util/home/tsconfig.json @@ -0,0 +1,9 @@ +{ + "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/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index 485e252c97..1cd6e07e17 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -42,6 +42,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/workflow/workflow-workerthread/tests/integration.spec.ts b/packages/workflow/workflow-workerthread/tests/integration.spec.ts index 0e1727f877..7b30f13f4f 100644 --- a/packages/workflow/workflow-workerthread/tests/integration.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/integration.spec.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import { AgentId } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' import * as spawn from '@deepseek-ai/dsh-subagent-spawn' @@ -26,11 +23,7 @@ type Script = ConstructorParameters[0] async function setup(script: Script) { const ctx = new Context() const adapter = new MockAdapter(script) - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx) await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb4ffd1d54..75ebab5ef6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,12 +155,18 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../bash-local + '@deepseek-ai/dsh-home': + specifier: workspace:^ + version: link:../../util/home '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -170,6 +176,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -228,6 +240,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit '@deepseek-ai/dsh-compact': specifier: workspace:^ version: link:../compact @@ -240,9 +255,6 @@ importers: '@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-tools': specifier: workspace:^ version: link:../../core/tools @@ -262,6 +274,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -342,6 +357,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -547,6 +565,9 @@ importers: '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../fs/fs-local + '@deepseek-ai/dsh-home': + specifier: workspace:^ + version: link:../../util/home '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -706,6 +727,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs @@ -734,6 +758,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: @@ -746,15 +807,15 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm '@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-tools': specifier: workspace:^ version: link:../../core/tools @@ -786,6 +847,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash @@ -801,12 +865,15 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -826,6 +893,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash @@ -841,9 +911,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-system-prompt': + '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ - version: link:../../core/system-prompt + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -1114,6 +1187,9 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs + '@deepseek-ai/dsh-home': + specifier: workspace:^ + version: link:../../util/home '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill @@ -1149,6 +1225,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': @@ -1210,6 +1351,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1228,12 +1372,6 @@ importers: '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:^ version: link:../subagent-spawn - '@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.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1246,6 +1384,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1283,6 +1424,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../bash/bash-local @@ -1304,18 +1448,12 @@ importers: '@deepseek-ai/dsh-subagent-inprocess': specifier: workspace:^ version: link:../subagent-inprocess - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../bash/tool-bash '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../tool-subagent - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools 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) @@ -1379,6 +1517,30 @@ 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/support/agent-loop-testkit: + devDependencies: + '@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-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@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-tools': + specifier: workspace:^ + version: link:../../core/tools + 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) + packages/support/invariants: devDependencies: '@deepseek-ai/dsh-agent': @@ -1522,6 +1684,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1556,6 +1721,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash @@ -1777,12 +1945,24 @@ 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/home: + 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/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: @@ -1804,6 +1984,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 @@ -1958,6 +2144,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -2048,6 +2237,9 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../packages/fs/fs-policy + '@deepseek-ai/dsh-home': + specifier: workspace:^ + version: link:../../packages/util/home '@deepseek-ai/dsh-hook-protocol': specifier: workspace:^ version: link:../../packages/hooks/hook-protocol diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a9c0055a02..c456fae848 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -23,6 +23,7 @@ "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-hooks-claude": "workspace:^", "@deepseek-ai/dsh-hooks-codex": "workspace:^", diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 2e5753d276..f8a39137aa 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1400, + "AGENTS.md": 1500, "docs/AGENTS.md": 1100, "docs/architecture.md": 1790, "docs/cordis-primer.md": 600, diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 0ace1072d6..7549ee982b 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -1,7 +1,7 @@ /** - * Typecheck Markdown `ts` fences against workspace sources. `ignore-check` - * fences are reported as opt-outs; generated catalog fragments and - * `type-equiv` blocks are skipped here because their owning gates verify them. + * Typecheck Markdown `ts` fences against the workspace API. `ignore-check` fences are reported as + * opt-outs; generated catalog fragments and `type-equiv` blocks are skipped here because their + * owning gates verify them. A build-coordinated mode consumes existing declarations without emit. */ import { execFileSync } from 'node:child_process' @@ -45,25 +45,106 @@ function extractBlocks(absPath: string): Block[] { .map(f => ({ file, line: f.line, kind: f.kind, code: f.code })) } +const configHost: ts.ParseConfigFileHost = { + ...ts.sys, + getCurrentDirectory: () => root, + onUnRecoverableConfigFileDiagnostic(diagnostic) { + throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')) + }, +} + +/** Load root settings and redirect workspace aliases to declarations from the coordinated build. */ +function builtTypeCompilerOptions(): ts.CompilerOptions { + const configPath = join(root, 'tsconfig.json') + const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost) + if (!parsed) throw new Error(`doc-typecheck: cannot parse ${configPath}`) + if (parsed.errors.length > 0) { + throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n')) + } + if (parsed.options.paths === undefined) throw new Error('doc-typecheck: root tsconfig has no workspace paths') + const paths = Object.fromEntries(Object.entries(parsed.options.paths).map(([specifier, candidates]) => [ + specifier, + candidates.map((candidate) => { + if (!candidate.endsWith('/src')) { + throw new Error(`doc-typecheck: cannot map workspace source path to built declarations: ${candidate}`) + } + return `${candidate.slice(0, -'/src'.length)}/lib/types` + }), + ])) + const options: ts.CompilerOptions = { + ...parsed.options, + paths, + noEmit: true, + composite: false, + incremental: false, + declaration: false, + declarationMap: false, + sourceMap: false, + noUnusedLocals: false, + noUnusedParameters: false, + } + delete options.tsBuildInfoFile + return options +} + +/** Compile Markdown blocks as virtual files against declarations from the coordinated build. */ +function compileBlocksAgainstBuiltTypes(blocks: Block[]): readonly ts.Diagnostic[] { + const options = builtTypeCompilerOptions() + const sources = new Map() + for (const [index, block] of blocks.entries()) { + const fileName = resolve(root, '.doc-typecheck', `block-${index}.ts`) + sources.set(fileName, block.code.endsWith('\n') ? block.code : `${block.code}\n`) + } + + const baseHost = ts.createCompilerHost(options, true) + const host: ts.CompilerHost = { + ...baseHost, + fileExists(fileName) { + return sources.has(resolve(fileName)) || baseHost.fileExists(fileName) + }, + readFile(fileName) { + return sources.get(resolve(fileName)) ?? baseHost.readFile(fileName) + }, + getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile) { + const source = sources.get(resolve(fileName)) + if (source !== undefined) return ts.createSourceFile(fileName, source, languageVersion, true) + return baseHost.getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile) + }, + writeFile() { + throw new Error('doc-typecheck: noEmit compilation attempted to write output') + }, + } + const program = ts.createProgram([...sources.keys()], options, host) + return ts.getPreEmitDiagnostics(program) +} + +/** Render compiler diagnostics with virtual block paths mapped back to Markdown. */ +function formatDiagnostics(diagnostics: readonly ts.Diagnostic[], blocks: Block[]): string { + const formatted = ts.formatDiagnostics(diagnostics, { + getCanonicalFileName: fileName => fileName, + getCurrentDirectory: () => root, + getNewLine: () => ts.sys.newLine, + }) + return remapBlockPaths(formatted, blocks) +} + /** Reuse the repo typecheck graph references from a temp project one directory below root. */ function workspaceReferences(): { path: string }[] { const file = join(root, 'tsconfig.json') - // Parse with TypeScript's own JSONC reader, not a hand-rolled comment strip: - // a regex strip mistakes the `/*/` in a wildcard path candidate - // (`./packages/core/*/src`) for a block comment and corrupts the map. - const result = ts.readConfigFile(file, p => readFileSync(p, 'utf8')) + // Parse with TypeScript's own JSONC reader: a regex comment stripper corrupts the `/*/` path + // candidate in the workspace wildcard. + const result = ts.readConfigFile(file, path => readFileSync(path, 'utf8')) if (result.error) { throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`) } - // `config` is typed `any` by the TS API; narrow it to the one field we read. - const { references } = result.config as { compilerOptions: { paths: Record }; references: { path: string }[] } - return references.map(({ path }) => { - const relativeToTemp = path.startsWith('./') ? `../${path.slice(2)}` : `../${path}` - return { path: relativeToTemp } - }) + // `config` is typed `any` by the TS API; narrow it to the one field read here. + const { references } = result.config as { references: { path: string }[] } + return references.map(({ path }) => ({ + path: path.startsWith('./') ? `../${path.slice(2)}` : `../${path}`, + })) } -/** The standalone tsconfig for the temp typecheck project. */ +/** The standalone temp project used when no coordinated build owns declaration freshness. */ function tempTsconfig(): string { return JSON.stringify({ extends: '../tsconfig.json', @@ -77,6 +158,39 @@ function tempTsconfig(): string { }) } +/** Compile blocks through project references for the standalone command. */ +function compileBlocksStandalone(blocks: Block[]): string | undefined { + const tmp = mkdtempSync(join(root, '.doc-typecheck-')) + try { + writeFileSync(join(tmp, 'tsconfig.json'), tempTsconfig()) + for (const [index, block] of blocks.entries()) { + writeFileSync(join(tmp, `block-${index}.ts`), block.code.endsWith('\n') ? block.code : `${block.code}\n`) + } + try { + // Invoke tsc's JS entry through Node instead of a platform-specific shell shim. + execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-b', join(tmp, 'tsconfig.json')], { + cwd: root, + stdio: 'pipe', + }) + return undefined + } catch (error: unknown) { + const failed = error as { stdout?: Buffer; stderr?: Buffer } + return remapBlockPaths(`${failed.stdout?.toString() ?? ''}${failed.stderr?.toString() ?? ''}`, blocks) + } + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +} + +/** Map virtual or temporary block paths back to their owning Markdown fences. */ +function remapBlockPaths(output: string, blocks: Block[]): string { + return output.replace(/(?:[^\s:()]*[/\\])?block-(\d+)\.ts\((\d+),(\d+)\)/g, (_match, index: string, line: string, column: string) => { + const block = blocks[Number(index)] + if (!block) return `block-${index}.ts(${line},${column})` + return `${block.file} (block at line ${block.line}, +${line}:${column})` + }) +} + const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md'] const files: string[] = [] @@ -97,45 +211,24 @@ if (checked.length === 0) { process.exit(0) } -const tmp = mkdtempSync(join(root, '.doc-typecheck-')) -try { - writeFileSync(join(tmp, 'tsconfig.json'), tempTsconfig()) - const fileForBlock = new Map() - checked.forEach((block, i) => { - const name = `block-${i}.ts` - writeFileSync(join(tmp, name), block.code.endsWith('\n') ? block.code : `${block.code}\n`) - fileForBlock.set(name, block) - }) +const useBuiltTypes = process.env.DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT === '1' +const compilationError = useBuiltTypes + ? (() => { + const diagnostics = compileBlocksAgainstBuiltTypes(checked) + return diagnostics.length === 0 ? undefined : formatDiagnostics(diagnostics, checked) + })() + : compileBlocksStandalone(checked) +if (compilationError !== undefined) { + console.error('doc-typecheck: documentation code blocks failed to compile.\n') + console.error(compilationError) + process.exit(1) +} - try { - // tsc's JS entry via the current node, not the .bin shim: the extensionless - // shim is not spawnable on Windows (the CVE-2024-27980 class the sibling - // scripts hit), and the .cmd variant would need shell:true, which - // concatenates args UNESCAPED — a hazard for the temp project path. The JS - // entry behaves identically on every platform. - execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-b', join(tmp, 'tsconfig.json')], { cwd: root, stdio: 'pipe' }) - } catch (error: unknown) { - const failed = error as { stdout?: Buffer; stderr?: Buffer } - const out = `${failed.stdout?.toString() ?? ''}${failed.stderr?.toString() ?? ''}` - // Rewrite "block-N.ts(line,col)" to the real "file:fenceLine" for triage. - const remapped = out.replace(/(?:[^\s:()]*[/\\])?block-(\d+)\.ts\((\d+),(\d+)\)/g, (_m, idx: string, ln: string, col: string) => { - const block = fileForBlock.get(`block-${idx}.ts`) - if (!block) return `block-${idx}.ts(${ln},${col})` - return `${block.file} (block at line ${block.line}, +${ln}:${col})` - }) - console.error('doc-typecheck: documentation code blocks failed to compile.\n') - console.error(remapped) - process.exit(1) - } - - const ratio = ignored.length / ratioDenominator - const skipped = all.length - ratioDenominator - console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/catalog (checked elsewhere).`) - // Guard against the escape hatch becoming the norm. - if (ratioDenominator >= 4 && ratio > 0.5) { - console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`) - process.exit(1) - } -} finally { - rmSync(tmp, { recursive: true, force: true }) +const ratio = ignored.length / ratioDenominator +const skipped = all.length - ratioDenominator +console.log(`doc-typecheck: ${checked.length} block(s) compiled, ${ignored.length} ignored (${(ratio * 100).toFixed(0)}% opt-out), ${skipped} type-equiv/catalog (checked elsewhere).`) +// Guard against the escape hatch becoming the norm. +if (ratioDenominator >= 4 && ratio > 0.5) { + console.error(`doc-typecheck: too many blocks opt out of checking (${ignored.length}/${ratioDenominator}). Make them compile or delete them.`) + process.exit(1) } diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 41f1c9db58..1e182c37c0 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', @@ -100,15 +101,15 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Durable session persistence seam', mode: 'seam', implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'], - consumers: ['agent-loop', 'acp', 'session-query'], + consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, { 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', @@ -169,6 +170,13 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'], note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.', }, + { + key: 'bashEnv', + pkg: 'tool-bash', + title: 'Managed bash environment registry', + mode: 'core', + note: 'Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace.', + }, { key: 'sandbox', pkg: 'sandbox', @@ -250,6 +258,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', 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/run-gates.ts b/scripts/run-gates.ts index cd1bad20a2..fde83ff5ea 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -24,6 +24,7 @@ type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' interface Gate { id: string label: string + displayCommand: string command: string args: string[] needs?: string[] @@ -38,22 +39,39 @@ interface GateResult { durationMs: number stdout: string stderr: string + output: GateOutputChunk[] exitCode: number | null error?: string } +interface GateOutputChunk { + stream: 'stdout' | 'stderr' + text: string +} + interface RunningGate { gate: Gate promise: Promise } +interface ConcurrencyDefault { + workers: number + source: string +} + const root = resolve(import.meta.dirname, '..') const mode = parseMode(process.argv[2]) const gates = gatesForMode(mode) -const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', defaultConcurrency(gates.length)) +const concurrencyDefault = defaultConcurrency(mode, gates.length) +const concurrencyOverride = process.env.DSH_GATE_CONCURRENCY +const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', concurrencyDefault.workers) +const verbose = process.env.DSH_GATE_VERBOSE === '1' const startedAt = performance.now() -console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s).`) +const concurrencySource = concurrencyOverride === undefined || concurrencyOverride === '' + ? concurrencyDefault.source + : '$DSH_GATE_CONCURRENCY' +console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`) const results = await runGates(gates, maxConcurrency) printSummary(results, performance.now() - startedAt) @@ -78,8 +96,15 @@ function parseMode(raw: string | undefined): Mode { } } -function defaultConcurrency(total: number): number { - return Math.min(total, Math.max(4, availableParallelism())) +function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefault { + const available = availableParallelism() + const modeLimit = selectedMode === 'pre-push' ? Math.min(4, available) : available + return { + workers: Math.min(total, modeLimit), + source: selectedMode === 'pre-push' + ? `${available} available CPU(s), pre-push cap 4` + : `${available} available CPU(s)`, + } } function concurrencyFromEnv(name: string, fallback: number): number { @@ -96,6 +121,7 @@ function pnpmScript(id: string, script: string, options: Partial = {}): Ga return { id, label: options.label ?? script, + displayCommand: `pnpm run ${script}`, ...pnpmInvocation(['run', script]), ...options, } @@ -105,6 +131,7 @@ function pnpmExec(id: string, args: string[], options: Partial = {}): Gate return { id, label: options.label ?? `pnpm exec ${args.join(' ')}`, + displayCommand: `pnpm exec ${args.join(' ')}`, ...pnpmInvocation(['exec', ...args]), ...options, } @@ -162,7 +189,10 @@ function gatesForMode(selected: Mode): Gate[] { pnpmScript('snapshot', 'test:snapshot'), pnpmScript('build', 'build'), ...hygieneLeafGates({ artifactNeeds: ['build'] }), - ...docSyncLeafGates(), + ...docSyncLeafGates({ + docTypecheckNeeds: ['build'], + docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' }, + }), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), ] } @@ -277,9 +307,15 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { ] } -function docSyncLeafGates(): Gate[] { +function docSyncLeafGates(options: { + docTypecheckNeeds?: string[] + docTypecheckEnv?: Record +} = {}): Gate[] { + const docTypecheckOptions: Partial = {} + if (options.docTypecheckNeeds !== undefined) docTypecheckOptions.needs = options.docTypecheckNeeds + if (options.docTypecheckEnv !== undefined) docTypecheckOptions.env = options.docTypecheckEnv return [ - pnpmScript('doc-typecheck', 'doc-typecheck'), + pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions), pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }), pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }), pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }), @@ -310,6 +346,7 @@ function demoSmokeGate(options: { needs?: string[] } = {}): Gate { return { id: 'demo-smoke', label: 'demo smoke', + displayCommand: 'pnpm run demo:echo', ...pnpmInvocation(['run', 'demo:echo']), input: 'echo ci smoke\n', ...dependencyOptions, @@ -386,6 +423,7 @@ async function runGates(allGates: Gate[], maxActive: number): Promise { const started = performance.now() let stdout = '' let stderr = '' + const output: GateOutputChunk[] = [] + let spawnError: string | undefined - const exitCode = await new Promise((resolveExit, reject) => { + const exitCode = await new Promise((resolveExit) => { const child = spawn(gate.command, gate.args, { cwd: root, env: { ...process.env, ...gate.env }, @@ -429,19 +469,28 @@ async function runGate(gate: Gate): Promise { }) child.stdout.setEncoding('utf8') child.stderr.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - child.on('error', reject) + child.stdout.on('data', (chunk: string) => { + stdout += chunk + output.push({ stream: 'stdout', text: chunk }) + }) + child.stderr.on('data', (chunk: string) => { + stderr += chunk + output.push({ stream: 'stderr', text: chunk }) + }) + child.on('error', (error) => { + spawnError = `failed to start command: ${error.message}` + resolveExit(null) + }) child.on('close', resolveExit) if (gate.input !== undefined) child.stdin.end(gate.input) else child.stdin.end() }) - let status: GateStatus = exitCode === 0 ? 'passed' : 'failed' - let error: string | undefined + let status: GateStatus = exitCode === 0 && spawnError === undefined ? 'passed' : 'failed' + let error = spawnError if (status === 'passed' && gate.verify !== undefined) { try { - await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, exitCode }) + await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, output, exitCode }) } catch (verifyError: unknown) { status = 'failed' error = verifyError instanceof Error ? verifyError.message : String(verifyError) @@ -454,6 +503,7 @@ async function runGate(gate: Gate): Promise { durationMs: performance.now() - started, stdout, stderr, + output, exitCode, } if (error !== undefined) result.error = error @@ -462,9 +512,16 @@ async function runGate(gate: Gate): Promise { function printResult(result: GateResult): void { const seconds = (result.durationMs / 1000).toFixed(2) - console.log(`\n== ${result.status.toUpperCase()} ${result.gate.label} (${seconds}s) ==`) - process.stdout.write(result.stdout) - process.stderr.write(result.stderr) + if (result.status === 'passed' && !verbose) { + console.log(`run-gates: PASS ${result.gate.label} (${seconds}s)`) + return + } + + const heading = `${result.status.toUpperCase()} ${result.gate.label} (${seconds}s)` + const writeHeading = result.status === 'passed' ? console.log : console.error + writeHeading(`\n== ${heading} ==`) + if (result.status !== 'passed') console.error(`command: ${result.gate.displayCommand}`) + printOutput(result.output) if (result.error !== undefined) console.error(result.error) } @@ -474,4 +531,22 @@ function printSummary(results: GateResult[], durationMs: number): void { const skipped = results.filter(result => result.status === 'skipped').length const seconds = (durationMs / 1000).toFixed(2) console.log(`\nrun-gates: ${passed} passed, ${failed} failed, ${skipped} skipped in ${seconds}s.`) + + const unsuccessful = results.filter(result => result.status === 'failed' || result.status === 'skipped') + if (unsuccessful.length === 0) return + + console.error('run-gates: unsuccessful gates:') + for (const result of unsuccessful) { + const duration = (result.durationMs / 1000).toFixed(2) + const reason = result.error ?? (result.exitCode === null ? 'no exit code' : `exit ${result.exitCode}`) + console.error(` - ${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`) + console.error(` ${result.gate.displayCommand}`) + } +} + +function printOutput(output: GateOutputChunk[]): void { + for (const chunk of output) { + if (chunk.stream === 'stdout') process.stdout.write(chunk.text) + else process.stderr.write(chunk.text) + } } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 11d00ec714..78af0eabe8 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -47,13 +47,18 @@ { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionLocation", "source": "packages/session-persistence/session-persistence/src/index.ts" }, { "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" }, @@ -86,6 +91,8 @@ { "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalPolicy", "source": "packages/ui/user-approval/src/index.ts" }, { "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalRequest", "source": "packages/ui/user-approval/src/index.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "DshEnvironmentKey", "source": "packages/bash/bash/src/types.ts" }, + { "doc": "docs/core-data-structures/bash.md", "symbol": "DshEnvironment", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" }, @@ -153,6 +160,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 b968c2e679..ba3b2b2cc0 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -55,9 +55,12 @@ 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.' }, + 'packages/support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' }, 'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' }, 'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' }, 'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, @@ -68,7 +71,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' }, '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/home': { kind: 'indirect', reason: 'Only dsh-tool-bash exposes the resolved home to model commands.' }, '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 1542f78ffe..53f69b2cf5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -54,6 +54,7 @@ "./packages/tasks/*/src", "./packages/workflow/*/src", "./packages/web/*/src", + "./packages/spill/*/src", "./packages/timeout/*/src", "./packages/todo/*/src", "./packages/cordis/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index 4fece2e6f6..678c8cd818 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -11,8 +11,10 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/home" }, { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, + { "path": "./packages/util/retention" }, { "path": "./packages/llm/llm" }, { "path": "./packages/core/session" }, { "path": "./packages/core/scope" }, @@ -50,14 +52,19 @@ { "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" }, { "path": "./packages/ui/acp" }, { "path": "./packages/examples/acp-demo" }, { "path": "./packages/ui/app-boot" }, diff --git a/tsconfig.json b/tsconfig.json index 0a73f16ea4..2f433786bb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,8 +22,10 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/home" }, { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, + { "path": "./packages/util/retention" }, { "path": "./packages/llm/llm" }, { "path": "./packages/core/session" }, { "path": "./packages/core/scope" }, @@ -59,6 +61,7 @@ { "path": "./packages/fs/fs-local" }, { "path": "./packages/fs/fs-policy" }, { "path": "./packages/fs/tool-fs" }, + { "path": "./packages/fs/tool-fs-search" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, { "path": "./packages/web/web" }, @@ -67,8 +70,12 @@ { "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" }, { "path": "./packages/ui/acp" }, { "path": "./packages/examples/acp-demo" }, { "path": "./packages/ui/app-boot" }, diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index dbc51eae41..9753176f9d 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -1,6 +1,25 @@ +import { availableParallelism } from 'node:os' import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' +const DEFAULT_SNAPSHOT_MAX_CONCURRENCY = 5 + +function positiveIntFromEnv(name: string, fallback: number): number { + const raw = process.env[name] + if (raw === undefined || raw === '') return fallback + + const value = Number(raw) + if (!Number.isInteger(value) || value < 1) { + throw new Error(`${name} must be a positive integer, got ${JSON.stringify(raw)}`) + } + return value +} + +const snapshotMaxConcurrency = positiveIntFromEnv( + 'DSH_SNAPSHOT_MAX_CONCURRENCY', + Math.min(DEFAULT_SNAPSHOT_MAX_CONCURRENCY, availableParallelism()), +) + // Replay is the keyless default: boot the real ACP subprocess from recorded model scripts and diff // normalized transcript plus persisted-log goldens. `record` calls the real API and updates fixtures // and goldens; `refresh` replays committed scripts and updates only current goldens. Replay/refresh @@ -21,10 +40,12 @@ export default defineConfig({ plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { include: ['examples/*/tests/**/*.snapshot.ts', 'packages/sdk/*/tests/**/*.snapshot.ts'], - // Each test boots a subprocess; give it room, and run files one at a time - // (a record run hits the live API, and replay subprocess boot is heavy). + // Each test boots a subprocess; give it room and keep the worker file singular. Replay tests + // opt into bounded in-file concurrency, while record/refresh stay serial because they write + // fixtures. The environment knob restores serial replay with value 1 on constrained machines. testTimeout: 120_000, hookTimeout: 30_000, fileParallelism: false, + maxConcurrency: snapshotMaxConcurrency, }, }) diff --git a/website/.vitepress/config/api-sidebar.json b/website/.vitepress/config/api-sidebar.json index 62dd80ac50..cdb6e2bfac 100644 --- a/website/.vitepress/config/api-sidebar.json +++ b/website/.vitepress/config/api-sidebar.json @@ -38,6 +38,10 @@ "text": "ctx.bash", "link": "/zh-CN/api/harness/bash" }, + { + "text": "ctx.bashEnv", + "link": "/zh-CN/api/harness/bash-env" + }, { "text": "ctx.codeRuntime", "link": "/zh-CN/api/harness/code-runtime" @@ -78,6 +82,10 @@ "text": "ctx.skills", "link": "/zh-CN/api/harness/skills" }, + { + "text": "ctx.spillStore", + "link": "/zh-CN/api/harness/spill-store" + }, { "text": "ctx.subagents", "link": "/zh-CN/api/harness/subagents" diff --git a/website/zh-CN/api/harness/bash-env.md b/website/zh-CN/api/harness/bash-env.md new file mode 100644 index 0000000000..f8baf32bac --- /dev/null +++ b/website/zh-CN/api/harness/bash-env.md @@ -0,0 +1,49 @@ + + +# ctx.bashEnv + +`BashEnvRegistry` — provided by `@deepseek-ai/dsh-tool-bash`. + +Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model bash call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/tool-bash/src/index.ts#L102) + +### ctx.bashEnv.register(contributor) + +```ts website-api +register(contributor: BashEnvContributor): () => void +``` + +Register one environment contributor. Names and keys are unique; built-in keys are reserved. Registration is disposed with the calling plugin fiber. + +- `contributor` — declared key ownership and per-execution resolver. + +**Returns** the disposer that unregisters the contribution. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/tool-bash/src/index.ts#L123) + +### ctx.bashEnv.collect(execution) + +```ts website-api +collect(execution: ToolExecution): DshEnvironment +``` + +Build the trusted `DSH_*` snapshot for one bash tool execution. + +- `execution` — the current tool execution. + +**Returns** an immutable environment overlay containing built-ins and current contributions. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/tool-bash/src/index.ts#L165) + +### ctx.bashEnv.list() + +```ts website-api +list(): BashEnvVariableInfo[] +``` + +Enumerate plugin-contributed variables without executing their resolvers. + +**Returns** declarations sorted by environment variable name. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/tool-bash/src/index.ts#L197) diff --git a/website/zh-CN/api/harness/bash.md b/website/zh-CN/api/harness/bash.md index 3734ee8338..c3e4173763 100644 --- a/website/zh-CN/api/harness/bash.md +++ b/website/zh-CN/api/harness/bash.md @@ -11,7 +11,7 @@ Implementations must honor these semantics: - BashProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files. - Disposal kills all running background processes and awaits their exit. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L46) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L49) ### ctx.bash.sandboxMode @@ -21,7 +21,7 @@ get sandboxMode(): SandboxMode | undefined The sandbox mode this executor applies by default, or `undefined` when it does not sandbox commands. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L56) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L59) ### ctx.bash.resolve(request) @@ -35,7 +35,7 @@ Apply implementation-owned defaults and caps to a request before execution. **Returns** the fully-specified spec to hand to `run`/`start`. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L66) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L69) ### ctx.bash.run(spec) @@ -49,7 +49,7 @@ Run a command in the foreground; resolves when it finishes. **Returns** the outcome; nonzero exits, timeout kills, and abort kills resolve with a descriptive result rather than reject. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L74) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L77) ### ctx.bash.start(spec) @@ -63,4 +63,4 @@ Start a background process and return its handle immediately. **Returns** the live process handle (reads, kill, quiescence promise). -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L81) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L84) diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index d70a87263c..e1d4aca11d 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -18,7 +18,7 @@ A fully configured agent and live session were published. Setup is composition-o - `agent` — the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L139) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L151) ### agent/disposed @@ -32,7 +32,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef - `agent` — the exact agent removed from the registry. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L148) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L160) ### agent/error @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w - `step` — the step at which the failure surfaced. - `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L283) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L295) ### agent/pre-step @@ -68,7 +68,7 @@ Awaited serial checkpoint for session-surface mutation after prompt assembly and - `sessionPrefix` — the frozen request prefix. - `signal` — the turn abort signal. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L202) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L214) ### agent/prompt-submit @@ -84,7 +84,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca - `content` — the drained message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L212) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L224) ### agent/queued @@ -100,7 +100,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already - `content` — the accepted content blocks retained by the inbox. - `info` — the accepted source plus whether it entered as steering. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L167) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L179) ### agent/request @@ -117,7 +117,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha - `step` — the step whose request this is. - `config` — the config the loop would use (frozen); return a replacement to switch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L224) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L236) ### agent/session-prefix @@ -133,7 +133,7 @@ Compose request-only messages placed before derived history. The frozen result i - `prefix` — the frozen seed; return an extended replacement. - `signal` — aborts composition when the step is torn down. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L239) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L251) ### agent/session-start @@ -148,7 +148,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to - `agent` — the agent whose session lifecycle began. - `source` — why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L180) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L192) ### agent/status @@ -163,7 +163,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no - `agent` — the agent whose status flipped. - `status` — the status just entered (the transition's destination). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L157) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L169) ### agent/step-result @@ -180,7 +180,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va - `step` — the step that produced the message. - `message` — the assistant message as assembled from the stream. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L250) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L262) ### agent/turn-continuation @@ -196,7 +196,7 @@ Override whether the turn continues. The default continues after tool calls or s - `turn` — the turn being continued or stopped. - `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L260) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L272) ### agent/turn-stop @@ -211,7 +211,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a - `agent` — the agent whose composed continuation outcome may be stopped. - `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L270) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L282) ## approval/* @@ -244,7 +244,7 @@ Single-slot decision for the next FileSystem.editText. Calling `next()` yields a - `target` — the resolved target about to be edited. - `actor` — the opaque tool-execution context the decider keys off. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L59) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L61) ### fs/observed @@ -260,7 +260,7 @@ Record a successful observation. Listeners must be synchronous recorders: throws - `version` — the version the actor now holds as its observation. - `actor` — the observing tool-execution context; undefined records nothing useful. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L68) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L70) ### fs/write-intent @@ -275,7 +275,7 @@ Single-slot decision for the next FileSystem.writeText. Calling `next()` yields - `target` — the resolved target about to be written. - `actor` — the opaque tool-execution context the decider keys off. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L51) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L53) ## llm/* diff --git a/website/zh-CN/api/harness/fs.md b/website/zh-CN/api/harness/fs.md index 30afc2103b..1246bbd27f 100644 --- a/website/zh-CN/api/harness/fs.md +++ b/website/zh-CN/api/harness/fs.md @@ -6,22 +6,22 @@ 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. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L78) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L80) ### ctx.fs.resolve(path, opts?) ```ts website-api -abstract resolve(path: string, opts?: { cwd?: string }): Promise +abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise ``` Resolve a model/plugin-supplied path into a stable FsTarget. May perform I/O (a remote/sandboxed backend may need a round-trip to map a path to a stable identity), hence async even though the local backend only normalizes + realpaths. - `path` — the path to resolve; relative paths resolve against `opts.cwd`. -- `opts` — `cwd` overrides the backend's default base for relative paths. +- `opts` — optional cwd override and cancellation signal. **Returns** the stable target; the same file yields the same `targetKey`. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L92) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L94) ### ctx.fs.stat(target, signal?) @@ -36,7 +36,24 @@ Return target metadata, or `undefined` when the target does not exist. **Returns** metadata only, never content; undefined for an absent target. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L100) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L102) + +### ctx.fs.lstat(path, opts?, signal?) + +```ts website-api +abstract lstat(path: string, opts?: { cwd?: string }, 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: 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 resolve's cwd rules. `undefined` means the path is absent. + +- `path` — the path to inspect; relative paths resolve against `opts.cwd`. +- `opts` — `cwd` overrides the backend's default base for relative paths. +- `signal` — aborts the metadata round-trip. + +**Returns** metadata only, never content; undefined for an absent path. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L118) ### ctx.fs.readText(target, signal?) @@ -51,7 +68,7 @@ Read the whole regular text file as a single decoded string. **Returns** the full decoded UTF-8 content. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L108) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L126) ### ctx.fs.streamText(target, signal?) @@ -66,7 +83,7 @@ Stream the whole regular text file as decoded text chunks (same text semantics a **Returns** the chunk iterable, decoded and validated like `readText`. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L119) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L137) ### ctx.fs.listDir(target, signal?) @@ -81,7 +98,7 @@ List direct children of a directory in stable name order. Returns resolved child **Returns** one entry per direct child, in stable name order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L128) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L146) ### ctx.fs.writeText(target, content, expected?, signal?) @@ -98,7 +115,7 @@ Atomically create or replace UTF-8 text. `expected` guards intent and staleness; **Returns** the outcome, including the version the write produced. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L139) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L157) ### ctx.fs.editText(target, edit, expected?, signal?) @@ -115,4 +132,4 @@ Atomically edit literal text. When supplied, the version guard is checked before **Returns** the outcome, including the version the edit produced. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L151) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L169) diff --git a/website/zh-CN/api/harness/session-persistence.md b/website/zh-CN/api/harness/session-persistence.md index 64459bc1ae..e382a2f4a4 100644 --- a/website/zh-CN/api/harness/session-persistence.md +++ b/website/zh-CN/api/harness/session-persistence.md @@ -6,7 +6,21 @@ Durable append-only session storage. Implementations preserve contiguous, losslessly JSON-serializable events; append resolves only after durability, and load balances a complete interrupted tail without rewriting committed events. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L30) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L42) + +### ctx.sessionPersistence.locate(meta) + +```ts website-api +abstract locate(meta: SessionHeader): SessionLocation | undefined +``` + +Resolve this backend's independent local artifact for a session without reading, creating, flushing, or otherwise materializing it. Backends such as SQLite that do not own one artifact per session return `undefined`. + +- `meta` — the immutable session header whose artifact is requested. + +**Returns** the backend-specific absolute location, when one exists. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L54) ### ctx.sessionPersistence.create(meta) @@ -18,7 +32,7 @@ Register a new session's metadata. A backend MAY defer the physical write until - `meta` — the immutable header (id, version, cwd, lineage) to record. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L42) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L63) ### ctx.sessionPersistence.append(id, events) @@ -31,7 +45,7 @@ Durably persist a batch of events (called from the write-behind drain at the `se - `id` — the session the batch belongs to. - `events` — the contiguous batch to persist, in seq order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L53) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L74) ### ctx.sessionPersistence.load(id) @@ -45,7 +59,7 @@ Load a header and balanced contiguous log. A complete interrupted final turn is **Returns** the header and a log ending on a balanced `turn/end`. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L63) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L84) ### ctx.sessionPersistence.list() @@ -57,4 +71,4 @@ Lightweight listing from metadata, without a full-log parse. **Returns** one header per materialized session. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L69) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L90) diff --git a/website/zh-CN/api/harness/session-query.md b/website/zh-CN/api/harness/session-query.md index be870fa62e..f081950aa7 100644 --- a/website/zh-CN/api/harness/session-query.md +++ b/website/zh-CN/api/harness/session-query.md @@ -4,9 +4,9 @@ `SessionQueryService` — provided by `@deepseek-ai/dsh-session-query`. -Live-preferred logical-corpus and exact-event read service. +Live-preferred logical-corpus exact-read and relationship-tracing service. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-query/session-query/src/index.ts#L35) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-query/session-query/src/index.ts#L38) ### ctx.sessionQuery.listSessions() @@ -18,7 +18,7 @@ List the complete logical corpus using live-preferred records. **Returns** deterministic newest-first cloned session records. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-query/session-query/src/index.ts#L60) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-query/session-query/src/index.ts#L63) ### ctx.sessionQuery.listEvents(sessionId) @@ -32,7 +32,35 @@ List lightweight raw-log event records for one logical session. **Returns** event records in ascending seq order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-query/session-query/src/index.ts#L69) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-query/session-query/src/index.ts#L72) + +### ctx.sessionQuery.traceSession(sessionId) + +```ts website-api +async traceSession(sessionId: SessionId): Promise +``` + +Trace known ancestry and descendants from one corpus observation. + +- `sessionId` — logical session id to trace. + +**Returns** a complete lineage or an explicit unresolved parent boundary. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-query/session-query/src/index.ts#L83) + +### ctx.sessionQuery.traceEvent(request) + +```ts website-api +async traceEvent(request: SessionEventTraceRequest): Promise +``` + +Trace one event's direct positional and provenance relationships. + +- `request` — target session id and event seq. + +**Returns** direct links plus the target's positional replacement chain. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-query/session-query/src/index.ts#L94) ### ctx.sessionQuery.readEvent(request) @@ -46,4 +74,4 @@ Read one full event plus a bounded raw-log context window. **Returns** cloned target and neighboring events. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-query/session-query/src/index.ts#L79) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-query/session-query/src/index.ts#L104) diff --git a/website/zh-CN/api/harness/sessions.md b/website/zh-CN/api/harness/sessions.md index ba9aaa1183..5ff551f947 100644 --- a/website/zh-CN/api/harness/sessions.md +++ b/website/zh-CN/api/harness/sessions.md @@ -7,7 +7,7 @@ In-memory session store (`ctx.sessions`). Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L564) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L540) ### ctx.sessions.create(id?, options?) @@ -23,7 +23,7 @@ For an agent whose session must be torn down IN ORDER with its loop (so the loop **Returns** the live session, already entered and announced. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L593) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L569) ### ctx.sessions.prepare(id?, options?) @@ -38,7 +38,7 @@ Build a session WITHOUT entering it into the store — validate the id/cwd and c **Returns** the constructed session, NOT yet in the store. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L622) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L598) ### ctx.sessions.enter(session) @@ -53,7 +53,7 @@ Re-checks the id for a duplicate: `prepare` and `enter` are public cross-package **Returns** the detach disposer (publication hooks + store removal). When called from a synchronous `session/created` listener, removal and disposal wait until that creation dispatch unwinds. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L666) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L642) ### ctx.sessions.announce(session) @@ -65,7 +65,7 @@ Emit `session/created` exactly once for an entered session (with the carrier ent - `session` — the entered session to announce to listeners. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L721) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L697) ### ctx.sessions.flush(session) @@ -79,7 +79,7 @@ Dispatch the awaited `session/flush` durability checkpoint for `session`, with t **Returns** resolves when every flush listener has settled; after all settle, rejects with the first registered listener failure if any listener failed. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L773) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L749) ### ctx.sessions.get(id) @@ -93,7 +93,7 @@ Look up a live session. **Returns** the session, or undefined when no live session has that id. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L805) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L781) ### ctx.sessions.list() @@ -105,7 +105,7 @@ All live sessions, in creation order. **Returns** a fresh array; mutating it does not affect the store. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L813) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L789) ### ctx.sessions.fork(source, boundary?, childSessionId?) @@ -121,4 +121,4 @@ Create a live child session from a turn-enclosed prefix of a live source. `bound **Returns** The created live child session. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L830) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L806) diff --git a/website/zh-CN/api/harness/spill-store.md b/website/zh-CN/api/harness/spill-store.md new file mode 100644 index 0000000000..b7fd81c934 --- /dev/null +++ b/website/zh-CN/api/harness/spill-store.md @@ -0,0 +1,27 @@ + + +# ctx.spillStore + +`SpillStore` (abstract seam) — provided by `@deepseek-ai/dsh-spill`. + +Abstract spill storage service. Subclass, implement saveText, and load the subclass as a plugin — it registers as `ctx.spillStore` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). +Semantics every implementation must honor: +- saveText persists the FULL `content` verbatim and returns an opaque locator, exact byte length, and model-facing retrieval guidance. +- Storage is scoped by the request's SaveTextSpill.owner session; the backend chooses a private (not world-readable) location and a collision-free name derived from — never equal to — the caller's `suggestedName`. +- `saveText` REJECTS on a real storage failure (permissions, ENOSPC, backend unavailable); the caller decides how to degrade (the spill policy treats a rejection as best-effort and keeps the inline result). + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/spill/spill/src/index.ts#L45) + +### ctx.spillStore.saveText(input) + +```ts website-api +abstract saveText(input: SaveTextSpill): Promise +``` + +Persist `input.content` to a session-scoped spill artifact. + +- `input` — the owner, provenance, suggested name, and full text to save. + +**Returns** the saved artifact's `SpillRef`; rejects on a storage failure. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/spill/spill/src/index.ts#L55) diff --git a/website/zh-CN/api/harness/tools.md b/website/zh-CN/api/harness/tools.md index d3ed03cd9d..9d127b174a 100644 --- a/website/zh-CN/api/harness/tools.md +++ b/website/zh-CN/api/harness/tools.md @@ -6,7 +6,7 @@ Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L363) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L378) ### ctx.tools.register(definition) @@ -20,7 +20,7 @@ Register globally or in the calling agent scope. Scoped tools shadow globals; du **Returns** the exact disposer that unregisters the tool. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L453) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L468) ### ctx.tools.restrict(filter) @@ -34,7 +34,7 @@ Restrict global tools for the calling agent scope. Empty filters, unknown names, **Returns** the exact disposer that lifts this restriction. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L493) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L508) ### ctx.tools.guard(guard) @@ -48,7 +48,7 @@ Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A **Returns** the exact disposer that unregisters the guard. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L544) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L559) ### ctx.tools.get(name, scope?) @@ -63,7 +63,7 @@ Look up a tool as one scope sees it (scoped shadows global; a restricted-away gl **Returns** the definition the scope resolves, or undefined when none is visible. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L646) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L661) ### ctx.tools.schemas(scope?) @@ -77,7 +77,7 @@ Project visible definitions onto the allowlisted model-facing schema fields, exc **Returns** one deep-cloned schema per visible tool. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L656) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L671) ### ctx.tools.execute(exec) @@ -91,4 +91,4 @@ Execute through pre-policy, guards, around-dispatch, post-policy, and final noti **Returns** the materialized final result. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L679) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L694)