Merge compact-tool-pairing into token-meter-service

# Conflicts:
#	docs/event-producer-consumer.md
This commit is contained in:
Hypatia May
2026-07-15 15:27:10 +08:00
95 changed files with 1054 additions and 908 deletions

View File

@@ -140,7 +140,7 @@ export interface Config {
} }
``` ```
Source: [`packages/bash/bash-local/src/index.ts:21`](../packages/bash/bash-local/src/index.ts) Source: [`packages/bash/bash-local/src/index.ts:18`](../packages/bash/bash-local/src/index.ts)
## `@deepseek-ai/dsh-bash-sandbox` ## `@deepseek-ai/dsh-bash-sandbox`
@@ -597,6 +597,22 @@ 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:39`](../packages/skill/skill-local/src/index.ts)
## `@deepseek-ai/dsh-stdio`
Requires: `agents` · `userInteraction`
```ts config-catalog
/** Serializable plugin configuration (cordis-native, schemastery). */
export interface Config {
/** Banner printed once on start, before the first `> ` prompt. */
welcome?: string
/** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */
agent?: string
}
```
Source: [`packages/ui/stdio/src/index.ts:30`](../packages/ui/stdio/src/index.ts)
## `@deepseek-ai/dsh-stdio-agent` ## `@deepseek-ai/dsh-stdio-agent`
```ts config-catalog ```ts config-catalog
@@ -976,7 +992,7 @@ export interface Config {
export type ToolPresentationMode = 'native' | 'code' | 'both' export type ToolPresentationMode = 'native' | 'code' | 'both'
``` ```
Source: [`packages/core/tools/src/index.ts:308`](../packages/core/tools/src/index.ts) Source: [`packages/core/tools/src/index.ts:307`](../packages/core/tools/src/index.ts)
## `@deepseek-ai/dsh-user-approval` ## `@deepseek-ai/dsh-user-approval`
@@ -1026,7 +1042,7 @@ export interface WebServiceConfig {
} }
``` ```
Source: [`packages/web/web/src/index.ts:59`](../packages/web/web/src/index.ts) Source: [`packages/web/web/src/index.ts:55`](../packages/web/web/src/index.ts)
## `@deepseek-ai/dsh-web-fetch-local` ## `@deepseek-ai/dsh-web-fetch-local`
@@ -1041,10 +1057,8 @@ export interface Config {
maxResponseBytes?: number maxResponseBytes?: number
/** Maximum decoded body length in characters. */ /** Maximum decoded body length in characters. */
maxBodyChars?: number maxBodyChars?: number
/** Default fetch timeout in milliseconds. */ /** Default fetch timeout in milliseconds, within Node's timer range. */
timeoutMs?: number timeoutMs?: number
/** Upper bound for a per-request timeout override. */
maxTimeoutMs?: number
/** Maximum number of same-origin redirect hops to follow. */ /** Maximum number of same-origin redirect hops to follow. */
maxRedirects?: number maxRedirects?: number
/** `User-Agent` header sent on every request. */ /** `User-Agent` header sent on every request. */
@@ -1052,7 +1066,7 @@ export interface Config {
} }
``` ```
Source: [`packages/web/web-fetch-local/src/index.ts:34`](../packages/web/web-fetch-local/src/index.ts) Source: [`packages/web/web-fetch-local/src/index.ts:36`](../packages/web/web-fetch-local/src/index.ts)
## `@deepseek-ai/dsh-web-search-deepseek` ## `@deepseek-ai/dsh-web-search-deepseek`
@@ -1187,6 +1201,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/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-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
- `@deepseek-ai/dsh-jsonrpc-agent` ([`packages/ui/jsonrpc-agent/src/index.ts`](../packages/ui/jsonrpc-agent/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-agent` ([`packages/ui/jsonrpc-agent/src/index.ts`](../packages/ui/jsonrpc-agent/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-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
- `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts))
- `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts)) - `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts))

View File

@@ -266,7 +266,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:364`](../../packages/core/tools/src/index.ts) Source: [`packages/core/tools/src/index.ts:363`](../../packages/core/tools/src/index.ts)
## `ctx.userInteraction` — `UserInteractionService` ## `ctx.userInteraction` — `UserInteractionService`
@@ -285,7 +285,7 @@ The web access service. Registered as `ctx.web` (one instance per context).
Selection semantics (resolved at execution time, never order-dependent): Selection semantics (resolved at execution time, never order-dependent):
- A configured id that is registered and `status().available` → that provider. - A configured id that is registered and `available()` → that provider.
- A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`. - A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.
- A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. - A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
- No id configured, exactly one registered usable provider → that provider. - No id configured, exactly one registered usable provider → that provider.
@@ -295,11 +295,11 @@ Selection semantics (resolved at execution time, never order-dependent):
```ts cordis-catalog ```ts cordis-catalog
registerSearchProvider(provider: WebSearchProvider): () => void registerSearchProvider(provider: WebSearchProvider): () => void
registerFetchProvider(provider: WebFetchProvider): () => void registerFetchProvider(provider: WebFetchProvider): () => void
async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
``` ```
Source: [`packages/web/web/src/index.ts:78`](../../packages/web/web/src/index.ts) Source: [`packages/web/web/src/index.ts:74`](../../packages/web/web/src/index.ts)
## `ctx.workflows` — `WorkflowService` (abstract seam) ## `ctx.workflows` — `WorkflowService` (abstract seam)

View File

@@ -202,7 +202,6 @@ A long-running command started with `start()` is tracked as a `BashTask`. `BashT
```ts type-equiv ```ts type-equiv
interface BashTask { interface BashTask {
readonly id: BashTaskId readonly id: BashTaskId
readonly command: string
status: BashTaskStatus status: BashTaskStatus
/** Exit code once finished (null = killed by signal / still running). */ /** Exit code once finished (null = killed by signal / still running). */
exitCode: number | null exitCode: number | null

View File

@@ -32,7 +32,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [skills.md](skills.md) | the skill service: discovery priority, `SkillSummary`/`SkillDefinition`, session-prefix catalog, model-facing `skill` loading | | [skills.md](skills.md) | the skill service: discovery priority, `SkillSummary`/`SkillDefinition`, session-prefix catalog, model-facing `skill` loading |
| [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface | | [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 | | [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/capability status, `WebError` | | [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` |
| [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | | [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. > 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.

View File

@@ -137,7 +137,6 @@ type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
```ts type-equiv ```ts type-equiv
interface ToolExecutionResult { interface ToolExecutionResult {
callId: CallId
content: ContentBlock[] content: ContentBlock[]
isError: boolean isError: boolean
/** /**
@@ -167,6 +166,8 @@ interface ToolExecutionResult {
} }
``` ```
The result carries only the outcome. Call identity remains on the immutable `ToolExecution` that accompanies it through every hook and on the durable `tool/call` / `tool/result` session events, so wrappers cannot create a second, disagreeing identity.
The registry materializes and freezes the final accepted result immediately before `tools/result`. Its content, structured error, additional context, and presentation metadata must round-trip losslessly through JSON; an invalid outcome becomes a JSON-safe `isError` result, so the observed live outcome is safe for the later durable `tool/result` append. The registry materializes and freezes the final accepted result immediately before `tools/result`. Its content, structured error, additional context, and presentation metadata must round-trip losslessly through JSON; an invalid outcome becomes a JSON-safe `isError` result, so the observed live outcome is safe for the later durable `tool/result` append.
Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/execute` wrappers return a `ToolExecutionResult`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`: Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/execute` wrappers return a `ToolExecutionResult`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`:

View File

@@ -25,8 +25,6 @@ interface WebSearchRequest {
```ts type-equiv ```ts type-equiv
interface WebSearchResult { interface WebSearchResult {
readonly providerId: string
readonly query: string
readonly content?: string readonly content?: string
readonly sources: readonly WebSearchSource[] readonly sources: readonly WebSearchSource[]
readonly truncated: boolean readonly truncated: boolean
@@ -49,7 +47,6 @@ interface WebSearchSource {
```ts type-equiv ```ts type-equiv
interface WebFetchRequest { interface WebFetchRequest {
readonly url: string readonly url: string
readonly timeoutMs?: number
} }
``` ```
@@ -57,7 +54,6 @@ HTTP status is part of the fetched resource state, not automatically a failure:
```ts type-equiv ```ts type-equiv
interface WebFetchResult { interface WebFetchResult {
readonly providerId: string
readonly url: string readonly url: string
readonly statusCode: number readonly statusCode: number
readonly body: WebFetchBody readonly body: WebFetchBody
@@ -73,15 +69,9 @@ type WebFetchBody =
| { readonly kind: 'text'; readonly content: string } | { readonly kind: 'text'; readonly content: string }
``` ```
## Provider status ## Provider availability
A provider's `status()` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id, the ambiguous candidate set) in its code and message. A provider's `available(): boolean` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id or ambiguous candidate set) in its code and message.
```ts type-equiv
type WebProviderStatus =
| { readonly available: true }
| { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
```
Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `WEB_PROVIDER_AMBIGUOUS`, not first-wins. Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `WEB_PROVIDER_AMBIGUOUS`, not first-wins.

View File

@@ -7,8 +7,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners | | Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
@@ -16,7 +16,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio) |
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
@@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent), [`token-meter`](../packages/llm/token-meter) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:66`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:66`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |

View File

@@ -90,6 +90,7 @@ flowchart TD
pkg_acp_snapshot["acp-snapshot"] pkg_acp_snapshot["acp-snapshot"]
pkg_invariants["invariants"] pkg_invariants["invariants"]
pkg_llm_replay["llm-replay"] pkg_llm_replay["llm-replay"]
pkg_loader_smoke["loader-smoke"]
pkg_subagent_mock["subagent-mock"] pkg_subagent_mock["subagent-mock"]
end end
subgraph group_ui["packages/ui"] subgraph group_ui["packages/ui"]
@@ -99,6 +100,7 @@ flowchart TD
pkg_jsonrpc["jsonrpc"] pkg_jsonrpc["jsonrpc"]
pkg_jsonrpc_agent["jsonrpc-agent"] pkg_jsonrpc_agent["jsonrpc-agent"]
pkg_permission["permission"] pkg_permission["permission"]
pkg_stdio["stdio"]
pkg_stdio_agent["stdio-agent"] pkg_stdio_agent["stdio-agent"]
pkg_tool_ask_user["tool-ask-user"] pkg_tool_ask_user["tool-ask-user"]
pkg_user_approval["user-approval"] pkg_user_approval["user-approval"]
@@ -209,6 +211,10 @@ flowchart TD
pkg_permission --> pkg_sandbox pkg_permission --> pkg_sandbox
pkg_permission --> pkg_session pkg_permission --> pkg_session
pkg_permission --> pkg_user_approval pkg_permission --> pkg_user_approval
pkg_stdio --> pkg_agent
pkg_stdio --> pkg_llm
pkg_stdio --> pkg_session
pkg_stdio --> pkg_user_interaction
pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_agent
pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_llm
pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_scope
@@ -337,6 +343,7 @@ flowchart TD
pkg_stdio_agent --> pkg_llm pkg_stdio_agent --> pkg_llm
pkg_stdio_agent --> pkg_session pkg_stdio_agent --> pkg_session
pkg_stdio_agent --> pkg_session_persistence_jsonl pkg_stdio_agent --> pkg_session_persistence_jsonl
pkg_stdio_agent --> pkg_stdio
pkg_stdio_agent --> pkg_tool_ask_user pkg_stdio_agent --> pkg_tool_ask_user
pkg_stdio_agent --> pkg_tools pkg_stdio_agent --> pkg_tools
pkg_stdio_agent --> pkg_user_interaction pkg_stdio_agent --> pkg_user_interaction
@@ -350,6 +357,7 @@ flowchart TD
| [`skill`](../packages/skill/skill) | `skill` | — | | [`skill`](../packages/skill/skill) | `skill` | — |
| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — | | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — |
| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — |
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | — |
| [`app-boot`](../packages/ui/app-boot) | `ui` | — | | [`app-boot`](../packages/ui/app-boot) | `ui` | — |
| [`jsonrpc-agent`](../packages/ui/jsonrpc-agent) | `ui` | — | | [`jsonrpc-agent`](../packages/ui/jsonrpc-agent) | `ui` | — |
| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — |
@@ -390,6 +398,7 @@ flowchart TD
| [`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) | | [`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) |
| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) |
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`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) | | [`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), [`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), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`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`](../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) |
@@ -415,4 +424,4 @@ flowchart TD
| [`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-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) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |

View File

@@ -20,7 +20,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|---|---| |---|---|
| [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 |
| [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 |
| [Prune unused web seam fields](proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 |
| [Simplify session-log representation](proposed/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 | | [Simplify session-log representation](proposed/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 |
### Architecture ### Architecture
@@ -103,6 +102,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 | | [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 |
| [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 | | [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 |
| [Drop unconsumed skill provider events](implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 | | [Drop unconsumed skill provider events](implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 |
| [Prune unused web seam fields](implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 |
### Architecture ### Architecture

View File

@@ -64,7 +64,7 @@ flowchart LR
toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"] toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"]
``` ```
`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider status type, and error codes. It does not import tool, agent, session, LLM, or provider packages. `@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider availability contract, and error codes. It does not import tool, agent, session, LLM, or provider packages.
Provider packages depend only on `dsh-web` and Cordis. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service. Provider packages depend only on `dsh-web` and Cordis. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service.
@@ -77,52 +77,42 @@ Provider packages depend only on `dsh-web` and Cordis. They own credentials, end
```ts ```ts
interface WebSearchProvider { interface WebSearchProvider {
readonly id: string readonly id: string
status(): WebProviderStatus available(): boolean
search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
} }
interface WebFetchProvider { interface WebFetchProvider {
readonly id: string readonly id: string
status(): WebProviderStatus available(): boolean
fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
} }
interface WebService { interface WebService {
registerSearchProvider(provider: WebSearchProvider): () => void registerSearchProvider(provider: WebSearchProvider): () => void
registerFetchProvider(provider: WebFetchProvider): () => void registerFetchProvider(provider: WebFetchProvider): () => void
search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
}
interface WebExecContext {
readonly signal?: AbortSignal
} }
``` ```
`WebExecContext` is execution control, not business input. It carries only `signal`, so `tool-web` propagates turn cancellation, tool timeout, and agent disposal into provider network requests, SSE readers, and expensive decoding. It does not pass `ToolExecution` through the seam — that would make `dsh-web` depend on `dsh-tools`. The optional signal is execution control, not business input: `tool-web` passes `exec.signal` directly so turn cancellation, tool timeout, and agent disposal reach provider network requests, stream readers, and expensive decoding. The seam does not pass `ToolExecution` through — that would make `dsh-web` depend on `dsh-tools`.
Provider ids are stable strings and unique within their capability kind. Registering a duplicate search provider id or duplicate fetch provider id fails rather than silently replacing the old provider. Provider registration returns a disposer and follows the existing `ctx.tools.register()` / `ctx.systemPrompt.section()` pattern: the mutation is wrapped in `ctx.effect()` so the registration is torn down with the contributing fiber. Provider ids are stable strings and unique within their capability kind. Registering a duplicate search provider id or duplicate fetch provider id fails rather than silently replacing the old provider. Provider registration returns a disposer and follows the existing `ctx.tools.register()` / `ctx.systemPrompt.section()` pattern: the mutation is wrapped in `ctx.effect()` so the registration is torn down with the contributing fiber.
## Provider status and selection ## Provider availability and selection
Provider status and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `status()` must not make network calls. Provider availability and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `available()` must not make network calls.
`LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` follows the same discipline. The seam exposes no aggregated capability-status query — `search()` / `fetch()` derive the selection on each call from the configured provider id, the registered providers, and each provider's cheap local `status()`, and a selection failure is the structured `WebError` thrown at execution time, whose code answers "in which broad category does this capability fail" and whose message answers "exactly which provider/ids/reason." A caller that needs to know whether a capability can run executes and routes that error; nothing is stored as mutable service state. `LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` follows the same discipline. The seam exposes no aggregated capability-status query — `search()` / `fetch()` derive the selection on each call from the configured provider id, the registered providers, and each provider's cheap local `available()` boolean, and a selection failure is the structured `WebError` thrown at execution time. A caller that needs to know whether a capability can run executes and routes that error; nothing is stored as mutable service state.
`WebProviderStatus` is an input to selection, not a health system. `tool-web` never calls a provider's `status()` directly — its only path into the seam is `search()` / `fetch()` — so selection policy has one owner. The boolean is an input to selection, not a health system. `tool-web` never calls a provider's `available()` directly — its only path into the seam is `search()` / `fetch()` — so selection policy has one owner.
```ts
type WebProviderStatus =
| { readonly available: true }
| { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
```
Selection must not depend on registration order. Cordis load order, config ordering, and HMR timing are not product semantics. Selection must not depend on registration order. Cordis load order, config ordering, and HMR timing are not product semantics.
| Situation | Execution behavior | | Situation | Execution behavior |
|---|---| |---|---|
| A configured provider id is registered and `status().available === true` | runs that provider | | A configured provider id is registered and `available() === true` | runs that provider |
| A configured provider id is not registered | fails with `WEB_PROVIDER_CONFIGURED_MISSING` | | A configured provider id is not registered | fails with `WEB_PROVIDER_CONFIGURED_MISSING` |
| A configured provider id is registered but unavailable | fails with `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` | | A configured provider id is registered but unavailable | fails with `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
| No provider id is configured and exactly one provider for that kind is registered and available | runs that single provider | | No provider id is configured and exactly one provider for that kind is registered and available | runs that single provider |
@@ -184,8 +174,6 @@ interface WebSearchRequest {
} }
interface WebSearchResult { interface WebSearchResult {
readonly providerId: string
readonly query: string
readonly content?: string readonly content?: string
readonly sources: readonly WebSearchSource[] readonly sources: readonly WebSearchSource[]
readonly truncated: boolean readonly truncated: boolean
@@ -212,20 +200,17 @@ The `web_fetch` implementation is an anonymous public HTTP(S) fetch provider, `l
The seam request stays smaller than OpenCode's model-facing tool: The seam request stays smaller than OpenCode's model-facing tool:
- `url`: required HTTP(S) URL. - `url`: required HTTP(S) URL.
- `timeoutMs`: optional positive number capped by the provider.
The seam request deliberately does not include `format`, `prompt`, or provider-specific extraction controls. `format` is a presentation decision over a fetched resource; `prompt` is a higher-level LLM summarization instruction; extraction APIs such as Firecrawl, Exa, Tavily, or Parallel may not expose a concrete HTTP response. If the product later needs provider-backed page extraction, that is a separate `web_extract` capability or a deliberate widening of this seam — extract semantics are never smuggled into `web_fetch` by making every HTTP field optional. The seam request deliberately does not include a per-call timeout, `format`, `prompt`, or provider-specific extraction controls. Cancellation is the direct optional execution signal, while the fetch provider owns one deployment-configured timeout backstop. `format` is a presentation decision over a fetched resource; `prompt` is a higher-level LLM summarization instruction; extraction APIs such as Firecrawl, Exa, Tavily, or Parallel may not expose a concrete HTTP response. If the product later needs provider-backed page extraction, that is a separate `web_extract` capability or a deliberate widening of this seam — extract semantics are never smuggled into `web_fetch` by making every HTTP field optional.
HTTP status is part of the fetched resource state, not automatically a tool failure. A successful network fetch of a `404` or `500` response returns `WebFetchResult` with the status code and a bounded decoded body when the content type is supported. `WebError` is for failures to safely retrieve or represent the resource: invalid or blocked URL, redirect policy violation, timeout, abort, response too large, unsupported content type, provider failure, or network failure. HTTP status is part of the fetched resource state, not automatically a tool failure. A successful network fetch of a `404` or `500` response returns `WebFetchResult` with the status code and a bounded decoded body when the content type is supported. `WebError` is for failures to safely retrieve or represent the resource: invalid or blocked URL, redirect policy violation, timeout, abort, response too large, unsupported content type, provider failure, or network failure.
```ts ```ts
interface WebFetchRequest { interface WebFetchRequest {
readonly url: string readonly url: string
readonly timeoutMs?: number
} }
interface WebFetchResult { interface WebFetchResult {
readonly providerId: string
readonly url: string readonly url: string
readonly statusCode: number readonly statusCode: number
readonly body: WebFetchBody readonly body: WebFetchBody
@@ -257,11 +242,11 @@ SSRF / private-network protection (blocking private, loopback, link-local, multi
`dsh-tool-web` owns two `ToolDefinition`s: `web_search` and `web_fetch`. It owns model-facing JSON schemas, snake_case argument names, prompt sections, result rendering to `ContentBlock[]`, `presentCall`, and `presentResult`. `dsh-tool-web` owns two `ToolDefinition`s: `web_search` and `web_fetch`. It owns model-facing JSON schemas, snake_case argument names, prompt sections, result rendering to `ContentBlock[]`, `presentCall`, and `presentResult`.
`dsh-tool-web` must not enumerate providers or call provider `status()` directly. Its only path into the seam is `ctx.web.search()` / `ctx.web.fetch()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state. `dsh-tool-web` must not enumerate providers or call provider `available()` directly. Its only path into the seam is `ctx.web.search()` / `ctx.web.fetch()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state.
Tool registration is a minimal stable sync: on plugin startup the `dsh-tool-web` `Config` (`search?: boolean`, `fetch?: boolean`, both default `true`) enables or disables each web tool; an enabled tool is registered with a fiber-scoped disposer via the effect-based registry; neither tool is disposed merely because its selected provider is missing, unusable, or ambiguous; disposing the `tool-web` fiber tears down its registrations automatically. Tool registration is a minimal stable sync: on plugin startup the `dsh-tool-web` `Config` (`search?: boolean`, `fetch?: boolean`, both default `true`) enables or disables each web tool; an enabled tool is registered with a fiber-scoped disposer via the effect-based registry; neither tool is disposed merely because its selected provider is missing, unusable, or ambiguous; disposing the `tool-web` fiber tears down its registrations automatically.
Provider status changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time. Provider availability changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time.
The prompt guidance explains the semantic split — `web_search` for discovery and current information, `web_fetch` when the model needs the content of a specific URL — and the prompt and tool result tell the model to cite relevant URLs with markdown links. The prompt guidance explains the semantic split — `web_search` for discovery and current information, `web_fetch` when the model needs the content of a specific URL — and the prompt and tool result tell the model to cite relevant URLs with markdown links.

View File

@@ -57,9 +57,8 @@ Signal replacement is by **in-place mutation of `exec.signal`**, not by passing
`timeout-policy` owns both uses of the `TOOL_TIMEOUT` code: the internal deadline code passed to `deadline()`/`timeoutOf()` (scoped so a nested outer deadline reads as an ordinary cancel) and the structured tool-result error code. Its replacement result is: `timeout-policy` owns both uses of the `TOOL_TIMEOUT` code: the internal deadline code passed to `deadline()`/`timeoutOf()` (scoped so a nested outer deadline reads as an ordinary cancel) and the structured tool-result error code. Its replacement result is:
```ts ignore-check ```ts ignore-check
function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecutionResult { function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
return { return {
callId,
content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }], content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }],
isError: true, isError: true,
error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
@@ -75,7 +74,7 @@ No new session event is needed for reconstructability: `TOOL_TIMEOUT` is the fin
`web_fetch` and `web_search` are migrated. `dsh-tool-web` keeps ownership of their model-facing schemas, and those schemas expose no timeout knob: `web_fetch` dropped its `timeout_ms` parameter to match the reference-agent shape, and `web_search` stays query-only. The tool bodies do not import `@deepseek-ai/dsh-timeout`; they forward `exec.signal` to `ctx.web`. `web_fetch` and `web_search` are migrated. `dsh-tool-web` keeps ownership of their model-facing schemas, and those schemas expose no timeout knob: `web_fetch` dropped its `timeout_ms` parameter to match the reference-agent shape, and `web_search` stays query-only. The tool bodies do not import `@deepseek-ai/dsh-timeout`; they forward `exec.signal` to `ctx.web`.
`dsh-web-fetch-local` keeps a provider-level timeout (`timeoutMs`/`maxTimeoutMs`) as a large resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments; it owns no model-facing timeout. When a `TOOL_TIMEOUT` signal reaches the fetch provider first, provider-scoped classification treats it as upstream `WEB_ABORTED`, and the outer `tools/execute` wrapper replaces the final tool result with `TOOL_TIMEOUT`. A shipped web-tool deployment configures the provider backstop above the `timeout-policy` budget so the tool-call policy normally wins for model calls. `dsh-web-fetch-local` keeps one configured provider-level `timeoutMs` as a large resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments; it owns no model-facing timeout. When a `TOOL_TIMEOUT` signal reaches the fetch provider first, provider-scoped classification treats it as upstream `WEB_ABORTED`, and the outer `tools/execute` wrapper replaces the final tool result with `TOOL_TIMEOUT`. A shipped web-tool deployment configures the provider backstop above the `timeout-policy` budget so the tool-call policy normally wins for model calls.
`bash` stays on the current backend timeout path. `dsh-tool-bash` continues to expose `timeoutMs` and `run_in_background`; `dsh-bash-local` continues to use `@deepseek-ai/dsh-timeout` for `BASH_TIMEOUT`; hook bridges continue to call `runHook()` and pass `timeoutMs` through `ctx.bash`. This keeps foreground/background/hook behavior stable. `bash` stays on the current backend timeout path. `dsh-tool-bash` continues to expose `timeoutMs` and `run_in_background`; `dsh-bash-local` continues to use `@deepseek-ai/dsh-timeout` for `BASH_TIMEOUT`; hook bridges continue to call `runHook()` and pass `timeoutMs` through `ctx.bash`. This keeps foreground/background/hook behavior stable.

View File

@@ -10,7 +10,7 @@ The boundary bought package metadata, workspace and tsconfig references, module-
## Decision ## Decision
The `stdio-chat` module now lives inside `dsh-stdio-agent` with its runtime seam. Per-file tests cover EOF, rendering, disposal, and piped-versus-TTY behavior without replacing process globals. It retains the named Cordis plugin export shape consumed by the app; an `unwrapExports` assertion and keyless Loader smokes guard both the package and composed entry paths. The helper lives in `@deepseek-ai/dsh-stdio` as the terminal-channel plugin (`packages/ui/stdio/src/index.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio/tests/stdio.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/coding-agent` keep proving the composed tree boots through the real Loader (the stdio package's plugin-shape unit suite pins the explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash).
The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module. The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module.

View File

@@ -1,6 +1,6 @@
# RFC: Prune unused web seam fields # RFC: Prune unused web seam fields
Status: proposed Status: implemented
## Problem ## Problem
@@ -8,23 +8,18 @@ The web capability carries request/result/status values that every shipped imple
`WebFetchRequest.timeoutMs` is likewise never set by a production caller. `tool-web` supplies only the URL, uses the tool definition's timeout plus `exec.signal` for the caller deadline, and relies on the local provider's configured default as a backstop. The unused per-request override forces `web-fetch-local` to expose `maxTimeoutMs`, clamp two timeout sources, and document/test precedence no product path can select. `WebExecContext` is another one-field wrapper: every caller allocates `{ signal }` and every provider immediately unwraps `exec?.signal`; no second execution-control field exists. `WebFetchRequest.timeoutMs` is likewise never set by a production caller. `tool-web` supplies only the URL, uses the tool definition's timeout plus `exec.signal` for the caller deadline, and relies on the local provider's configured default as a backstop. The unused per-request override forces `web-fetch-local` to expose `maxTimeoutMs`, clamp two timeout sources, and document/test precedence no product path can select. `WebExecContext` is another one-field wrapper: every caller allocates `{ signal }` and every provider immediately unwraps `exec?.signal`; no second execution-control field exists.
## Proposal ## Decision
Remove the search/fetch `providerId` result echoes and search `query` echo; callers already own the request and provider selection. Shrink provider status to availability alone, preferably a boolean-returning method if that produces the clearest seam. Remove per-request fetch timeout, `maxTimeoutMs`, and their clamp/validation branches while retaining the provider's configurable default timeout and tool-level deadline. Replace `WebExecContext` with a direct optional `AbortSignal` parameter. The web seam omits the search/fetch `providerId` result echoes and search `query` echo; callers already own the request and provider selection. Providers expose availability as a boolean-returning method. Fetch requests have no per-request timeout or `maxTimeoutMs` clamp; the local provider retains its configurable default timeout and the tool retains its own deadline. Provider methods receive a direct optional `AbortSignal` instead of a one-field `WebExecContext` wrapper.
Update all web implementations, the model-facing tool, package READMEs/JSDoc, type-equivalence records, and tests. Keep the interface/implementation/consumer package split, provider selection, source citations, final-URL/status data, truncation reporting, and all safety limits. All web implementations and the model-facing tool use the smaller contract. The interface/implementation/consumer package split, provider selection, source citations, final-URL/status data, truncation reporting, and safety limits remain.
## Alternatives considered ## Alternatives considered
**Keep self-describing results, per-request deadlines, and an extensible execution-context object.** Result echoes can help generic telemetry, a request timeout can help trusted programmatic callers, and the wrapper leaves room for future controls. No such consumer/second field exists; carrying duplicate identity, a second deadline policy, and wrap/unwrap plumbing through every provider makes the current contract harder to implement and explain. If telemetry or per-call budget control arrives, it should define which deadline wins, where provider identity is observed, and whether multiple controls justify a context object. **Keep self-describing results, per-request deadlines, and an extensible execution-context object.** Result echoes can help generic telemetry, a request timeout can help trusted programmatic callers, and the wrapper leaves room for future controls. No such consumer/second field exists; carrying duplicate identity, a second deadline policy, and wrap/unwrap plumbing through every provider makes the current contract harder to implement and explain. If telemetry or per-call budget control arrives, it should define which deadline wins, where provider identity is observed, and whether multiple controls justify a context object.
## Acceptance criteria ## Consequences
- Every retained web request/result/status field has a production reader or is required to execute the provider request. Every retained web request/result field is consumed by production code or required to execute the provider request. Tool-visible search/fetch output, provider fallback, abort behavior, the configured timeout backstop, truncation, and citations remain covered without a request-timeout precedence branch or execution-context wrapper.
- Tool-visible search/fetch output, provider fallback, abort behavior, configured timeout backstop, truncation, and citations remain covered.
- No `maxTimeoutMs`, request-timeout precedence branch, or one-field execution-context wrapper remains.
- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass.
## Risks
Pre-release programmatic callers lose result provenance echoes and per-request fetch deadlines. The provider still has a deployment-configurable timeout and respects cancellation, so the simplification removes configurability rather than a safety bound. Pre-release programmatic callers lose result provenance echoes and per-request fetch deadlines. The provider still has a deployment-configurable timeout and respects cancellation, so the simplification removes configurability rather than a safety bound.

View File

@@ -26,7 +26,7 @@ Amend the session-surface and reconstructable-request RFCs where they describe t
## Acceptance criteria ## Acceptance criteria
- `SurfaceManager.nodes` is one ordered seq array with no `SurfaceNode`, link fields, or seq-to-node map; incremental append processing and the internal replace-generation signal remain, while the separate public `invalidate()` deletion stays owned by the dead-surface RFC. - `SurfaceManager.nodes` is one ordered seq array with no `SurfaceNode`, link fields, or seq-to-node map; incremental append processing and the internal replace-generation signal remain.
- Replaying full changed-header snapshots reconstructs exactly the same requests; no header-delta event/type/codec remains. - Replaying full changed-header snapshots reconstructs exactly the same requests; no header-delta event/type/codec remains.
- A v0 seed or persisted log containing legacy `request/header-delta` is rejected before replay, with coverage for JSONL and SQLite load paths. - A v0 seed or persisted log containing legacy `request/header-delta` is rejected before replay, with coverage for JSONL and SQLite load paths.
- New-shape v0 JSONL/SQLite replay, provenance, crash repair, compaction, snapshots, invariants, typecheck, coverage, doc-sync, build, and hygiene pass. - New-shape v0 JSONL/SQLite replay, provenance, crash repair, compaction, snapshots, invariants, typecheck, coverage, doc-sync, build, and hygiene pass.

View File

@@ -13,7 +13,7 @@ Each example has both:
Mock-only examples require only the keyless tier; state that exception in the test. Mock-only examples require only the keyless tier; state that exception in the test.
Temp-cwd keyless smokes set `TSX_TSCONFIG_PATH` to the root tsconfig and pass `--expose-internals` when loading HMR. Keyless stdio smokes use `@deepseek-ai/dsh-loader-smoke` for isolation, root-tsconfig loading, subprocess lifecycle, diagnostics, EOF, and cleanup; tests supply paths, environment, input, and assertions.
Do not inventory example tests here; the `tests/` trees and root scripts are authoritative. Do not inventory example tests here; the `tests/` trees and root scripts are authoritative.

View File

@@ -15,6 +15,16 @@
- id: llm-deepseek - id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek' name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true disabled: true
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
config:
runnerCommand:
- bash
- -c
- while [ "$1" != "--" ]; do shift; done; shift; exec "$@"
- passthrough-runner
runnerFailureSignatures:
- 'passthrough-runner: profile rejected'
- insert: - insert:
- id: llm-replay - id: llm-replay
name: '@deepseek-ai/dsh-llm-replay' name: '@deepseek-ai/dsh-llm-replay'

View File

@@ -1,92 +1,27 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
/** /**
* Keyless Loader-path smoke for the Code Mode overlay: boot the real example through the * Keyless Loader-path smoke for the Code Mode overlay: boot the real include
* `@deepseek-ai/dsh-stdio-agent` bin against `code-mode.cordis.yml` (the cordis Loader, * tree through stdio-agent and `code-mode.cordis.yml`, then close stdin without
* `unwrapExports`, the include patches over ./cordis.yml, the worker-thread code runtime, and * a prompt and assert the banner. No model or `run_code` turn runs.
* the registry in `mode: code`), then close stdin with no prompt and assert the Code Mode
* banner + a clean exit. A dummy key satisfies adapter boot, but no prompt means
* no model call; the with-key proof lives in `code-mode.e2e.ts`.
*/ */
const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) const configPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url))
const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
// Dev/test run UNBUILT: resolve `@deepseek-ai/dsh-*` through the root tsconfig
// `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside
// the repo, so point it at the repo tsconfig.
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
// Under parallel e2e load, cold tsx/Loader startup can exceed a tight deadline;
// 30s still detects a wedged child.
const PROCESS_TIMEOUT_MS = 30_000
// Leave enough room for the process-owned timeout to report captured output
// before Vitest aborts the test itself.
const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
let child: ChildProcessWithoutNullStreams | undefined
let workdir: string | undefined
afterEach(async () => {
if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
child = undefined
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
workdir = undefined
})
async function bootAndEof(): Promise<{ stdout: string; code: number }> {
workdir = await mkdtemp(join(tmpdir(), 'code-mode-smoke-'))
const cwd = workdir
return new Promise((resolve, reject) => {
const proc = spawn(
process.execPath,
// --expose-internals: the included cordis.yml loads the HMR plugin (mirrors demo:code-mode).
['--expose-internals', '--import', tsxLoader, binScript, configPath],
{
cwd,
env: {
...process.env,
TSX_TSCONFIG_PATH: repoTsconfig,
// A dummy key so llm-deepseek's apply() (key-PRESENT check only) boots.
// No prompt is sent, so the adapter never streams — no network call.
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
},
stdio: ['pipe', 'pipe', 'pipe'],
},
)
child = proc
let stdout = ''
let stderr = ''
proc.stdout.setEncoding('utf8')
proc.stdout.on('data', (chunk: string) => { stdout += chunk })
proc.stderr.setEncoding('utf8')
proc.stderr.on('data', (chunk: string) => { stderr += chunk })
const timer = setTimeout(() => {
proc.kill('SIGKILL')
reject(new Error(`code-mode overlay did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, PROCESS_TIMEOUT_MS)
proc.on('exit', (code) => {
clearTimeout(timer)
if (code === 0) resolve({ stdout, code })
else reject(new Error(`code-mode overlay exited ${code}. stderr:\n${stderr}`))
})
proc.on('error', (err) => { clearTimeout(timer); reject(err) })
// No prompt — just EOF, so the stdio UI exits without ever running a turn.
proc.stdin.end()
})
}
describe('code-mode overlay keyless smoke (real code-mode.cordis.yml via the Loader)', () => { describe('code-mode overlay keyless smoke (real code-mode.cordis.yml via the Loader)', () => {
it('boots the Code Mode plugin tree, prints its banner, and exits cleanly on EOF', async () => { it('boots the Code Mode plugin tree, prints its banner, and exits cleanly on EOF', async () => {
const { stdout, code } = await bootAndEof() const { stdout } = await runLoaderSmoke({
expect(code).toBe(0) label: 'code-mode overlay',
tempDirPrefix: 'code-mode-smoke-',
binScript,
configPath,
tsconfigPath,
env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' },
})
expect(stdout).toContain('code-mode agent ready.') expect(stdout).toContain('code-mode agent ready.')
}, TEST_TIMEOUT_MS) }, LOADER_SMOKE_TEST_TIMEOUT_MS)
}) })

View File

@@ -1,90 +1,28 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
/** /**
* Boots the real example through the stdio bin and `cordis.yml`, covering Loader, * Keyless Loader-path smoke for examples/coding-agent: boot the real example
* `unwrapExports`, the full plugin tree, the agent-core bundle, and the readline module. * through the stdio-agent bin and its `cordis.yml`, then close stdin without a
* A dummy key permits startup; closing stdin before a prompt prevents network calls, * prompt and assert the banner. The dummy key satisfies adapter construction;
* while with-key suites cover product behavior. * immediate EOF guarantees there is no model call.
*/ */
// TODO(loader-smoke-harness): share spawn/tempdir/timeout/EOF setup with the other keyless smoke tests.
// The temp-cwd child needs absolute bin and config paths.
const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
// The temp cwd cannot discover the root tsconfig used for unbuilt package aliases.
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
// Allow cold Loader startup under parallel load while still detecting hangs.
const PROCESS_TIMEOUT_MS = 30_000
// Let the child timeout report captured output before Vitest aborts.
const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
let child: ChildProcessWithoutNullStreams | undefined
let workdir: string | undefined
afterEach(async () => {
if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
child = undefined
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
workdir = undefined
})
async function bootAndEof(): Promise<{ stdout: string; code: number }> {
workdir = await mkdtemp(join(tmpdir(), 'coding-smoke-'))
const cwd = workdir
return new Promise((resolve, reject) => {
const proc = spawn(
process.execPath,
// --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:repl).
['--expose-internals', '--import', tsxLoader, binScript, configPath],
{
cwd,
env: {
...process.env,
TSX_TSCONFIG_PATH: repoTsconfig,
// A dummy key so llm-deepseek's apply() (key-PRESENT check only) boots.
// No prompt is sent, so the adapter never streams — no network call.
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
},
stdio: ['pipe', 'pipe', 'pipe'],
},
)
child = proc
let stdout = ''
let stderr = ''
proc.stdout.setEncoding('utf8')
proc.stdout.on('data', (chunk: string) => { stdout += chunk })
proc.stderr.setEncoding('utf8')
proc.stderr.on('data', (chunk: string) => { stderr += chunk })
const timer = setTimeout(() => {
proc.kill('SIGKILL')
reject(new Error(`coding-agent did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, PROCESS_TIMEOUT_MS)
proc.on('exit', (code) => {
clearTimeout(timer)
if (code === 0) resolve({ stdout, code })
else reject(new Error(`coding-agent exited ${code}. stderr:\n${stderr}`))
})
proc.on('error', (err) => { clearTimeout(timer); reject(err) })
// No prompt — just EOF, so the stdio UI exits without ever running a turn.
proc.stdin.end()
})
}
describe('coding-agent keyless smoke (real cordis.yml via the Loader)', () => { describe('coding-agent keyless smoke (real cordis.yml via the Loader)', () => {
it('boots the full plugin tree, prints its banner, and exits cleanly on EOF', async () => { it('boots the full plugin tree, prints its banner, and exits cleanly on EOF', async () => {
const { stdout, code } = await bootAndEof() const { stdout } = await runLoaderSmoke({
expect(code).toBe(0) label: 'coding-agent',
tempDirPrefix: 'coding-smoke-',
binScript,
configPath,
tsconfigPath,
env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' },
})
expect(stdout).toContain('agent REPL ready.') expect(stdout).toContain('agent REPL ready.')
}, TEST_TIMEOUT_MS) }, LOADER_SMOKE_TEST_TIMEOUT_MS)
}) })

View File

@@ -1,94 +1,27 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
/** /**
* Keyless Loader-path smoke for examples/cordis-agent: boot the real example through the * Keyless Loader-path smoke for examples/cordis-agent: boot the real tree,
* `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` — the cordis Loader, * including tool-cordis resolved by package name, then close stdin without a
* `unwrapExports`, the full plugin tree INCLUDING the `@deepseek-ai/dsh-tool-cordis` package * prompt and assert the banner. The dummy key never reaches a model call.
* resolved by name (whose `inject` would crash a collapsed export shape at load, see
* docs/postmortem/0001) — then close stdin with no prompt and assert the ready banner + a
* clean exit. A dummy key satisfies adapter boot, but no prompt means no network
* call; `cordis-tools.e2e.ts` owns the with-key product proof.
*/ */
// The temp-cwd child needs absolute bin and config paths.
const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
// Dev/test run UNBUILT: resolve `@deepseek-ai/dsh-*` through the root tsconfig
// `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside
// the repo, so point it at the repo tsconfig (root is three levels up).
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
// Under parallel e2e load, cold tsx/Loader startup can exceed a tight deadline;
// 30s still detects a wedged child.
const PROCESS_TIMEOUT_MS = 30_000
// Leave enough room for the process-owned timeout to report captured output
// before Vitest aborts the test itself.
const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
let child: ChildProcessWithoutNullStreams | undefined
let workdir: string | undefined
afterEach(async () => {
if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
child = undefined
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
workdir = undefined
})
async function bootAndEof(): Promise<{ stdout: string; code: number }> {
workdir = await mkdtemp(join(tmpdir(), 'cordis-smoke-'))
const cwd = workdir
return new Promise((resolve, reject) => {
const proc = spawn(
process.execPath,
// --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:cordis).
['--expose-internals', '--import', tsxLoader, binScript, configPath],
{
cwd,
env: {
...process.env,
TSX_TSCONFIG_PATH: repoTsconfig,
// A dummy key so llm-deepseek's apply() (key-PRESENT check only) boots.
// No prompt is sent, so the adapter never streams — no network call.
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
},
stdio: ['pipe', 'pipe', 'pipe'],
},
)
child = proc
let stdout = ''
let stderr = ''
proc.stdout.setEncoding('utf8')
proc.stdout.on('data', (chunk: string) => { stdout += chunk })
proc.stderr.setEncoding('utf8')
proc.stderr.on('data', (chunk: string) => { stderr += chunk })
const timer = setTimeout(() => {
proc.kill('SIGKILL')
reject(new Error(`cordis-agent did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, PROCESS_TIMEOUT_MS)
proc.on('exit', (code) => {
clearTimeout(timer)
if (code === 0) resolve({ stdout, code })
else reject(new Error(`cordis-agent exited ${code}. stderr:\n${stderr}`))
})
proc.on('error', (err) => { clearTimeout(timer); reject(err) })
// No prompt — just EOF, so the stdio UI exits without ever running a turn.
proc.stdin.end()
})
}
describe('cordis-agent keyless smoke (real cordis.yml via the Loader)', () => { describe('cordis-agent keyless smoke (real cordis.yml via the Loader)', () => {
it('boots the full plugin tree incl. tool-cordis, prints its banner, and exits cleanly on EOF', async () => { it('boots the full plugin tree incl. tool-cordis, prints its banner, and exits cleanly on EOF', async () => {
const { stdout, code } = await bootAndEof() const { stdout } = await runLoaderSmoke({
expect(code).toBe(0) label: 'cordis-agent',
tempDirPrefix: 'cordis-smoke-',
binScript,
configPath,
tsconfigPath,
env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' },
})
expect(stdout).toContain('cordis-agent ready.') expect(stdout).toContain('cordis-agent ready.')
}, TEST_TIMEOUT_MS) }, LOADER_SMOKE_TEST_TIMEOUT_MS)
}) })

View File

@@ -1,111 +1,43 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
/** /**
* Keyless Loader-path smoke for examples/echo-agent: boot the real example through the * Keyless-by-nature Loader-path coverage for examples/echo-agent. The real
* `@deepseek-ai/dsh-stdio-agent` bin against this example's `cordis.yml` (the cordis Loader, * tree uses its deterministic mock model, so this suite is both the boot smoke
* `unwrapExports`, the whole plugin tree), pipe a script of stdin lines, and assert the * and the complete behavior proof for the example.
* rendered stdout. The mock adapter is network-free, making this the complete
* smoke; inputs cover both the echo-tool round trip and direct-reply branch.
*/ */
// The temp-cwd child needs absolute bin and config paths.
const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
// The temp cwd is outside the repo, so point tsx at the root config that resolves
// unbuilt workspace packages through `paths`.
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
// Under parallel e2e load, cold tsx/Loader startup can exceed a tight deadline;
// 30s still detects a wedged child.
const PROCESS_TIMEOUT_MS = 30_000
// Leave enough room for the process-owned timeout to report captured output
// before Vitest aborts the test itself.
const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
let child: ChildProcessWithoutNullStreams | undefined async function runEcho(stdinLines: readonly string[]): Promise<string> {
let workdir: string | undefined const { stdout } = await runLoaderSmoke({
label: 'echo-agent',
afterEach(async () => { tempDirPrefix: 'echo-smoke-',
if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') binScript,
child = undefined configPath,
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) tsconfigPath,
workdir = undefined stdinLines,
})
/**
* Boot echo-agent, write `lines` to its stdin, close stdin, and resolve with
* the full stdout once the process exits (the stdio UI exits on EOF after the
* agent settles). Rejects on a non-zero exit or the process deadline.
*/
async function runEcho(lines: string[]): Promise<{ stdout: string; code: number }> {
workdir = await mkdtemp(join(tmpdir(), 'echo-smoke-'))
const cwd = workdir
return new Promise((resolve, reject) => {
const proc = spawn(
process.execPath,
// --expose-internals: the example's cordis.yml loads the HMR plugin, which requires it
// (mirrors the `demo:echo` script).
['--expose-internals', '--import', tsxLoader, binScript, configPath],
{
cwd,
env: {
...process.env,
TSX_TSCONFIG_PATH: repoTsconfig,
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
},
stdio: ['pipe', 'pipe', 'pipe'],
},
)
child = proc
let stdout = ''
let stderr = ''
proc.stdout.setEncoding('utf8')
proc.stdout.on('data', (chunk: string) => { stdout += chunk })
proc.stderr.setEncoding('utf8')
proc.stderr.on('data', (chunk: string) => { stderr += chunk })
const timer = setTimeout(() => {
proc.kill('SIGKILL')
reject(new Error(`echo-agent did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, PROCESS_TIMEOUT_MS)
proc.on('exit', (code) => {
clearTimeout(timer)
if (code === 0) resolve({ stdout, code })
else reject(new Error(`echo-agent exited ${code}. stderr:\n${stderr}`))
})
proc.on('error', (err) => { clearTimeout(timer); reject(err) })
// Feed the script, then EOF so the stdio UI exits after the agent settles.
for (const line of lines) proc.stdin.write(`${line}\n`)
proc.stdin.end()
}) })
return stdout
} }
describe('echo-agent keyless smoke (real cordis.yml via the Loader)', () => { describe('echo-agent keyless smoke (real cordis.yml via the Loader)', () => {
it('boots, prints its welcome banner, and exits cleanly on stdin EOF', async () => { it('boots, prints its welcome banner, and exits cleanly on stdin EOF', async () => {
const { stdout, code } = await runEcho([]) expect(await runEcho([])).toContain('echo-agent ready.')
expect(code).toBe(0) }, LOADER_SMOKE_TEST_TIMEOUT_MS)
expect(stdout).toContain('echo-agent ready.')
}, TEST_TIMEOUT_MS)
it('runs the echo tool round-trip for an "echo …" line', async () => { it('runs the echo tool round-trip for an "echo …" line', async () => {
const { stdout } = await runEcho(['echo hello world']) const stdout = await runEcho(['echo hello world'])
// mock-llm.ts emits a tool-call for the echo tool; echo-tool.ts uppercases.
expect(stdout).toContain('[tool call] echo') expect(stdout).toContain('[tool call] echo')
expect(stdout).toContain('[tool result] ECHO: HELLO WORLD') expect(stdout).toContain('[tool result] ECHO: HELLO WORLD')
}, TEST_TIMEOUT_MS) }, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('streams a direct canned reply for a non-echo line', async () => { it('streams a direct canned reply for a non-echo line', async () => {
const { stdout } = await runEcho(['just chatting']) const stdout = await runEcho(['just chatting'])
// The direct-response branch of mock-llm.ts quotes the input back.
expect(stdout).toContain('just chatting') expect(stdout).toContain('just chatting')
expect(stdout).not.toContain('[tool call]') expect(stdout).not.toContain('[tool call]')
}, TEST_TIMEOUT_MS) }, LOADER_SMOKE_TEST_TIMEOUT_MS)
}) })

View File

@@ -45,6 +45,11 @@
"project": ["src/**/*.ts", "tests/**/*.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"],
"ignoreDependencies": ["cordis"] "ignoreDependencies": ["cordis"]
}, },
"packages/support/loader-smoke": {
"entry": ["tests/**/*.spec.ts", "tests/fixtures/*.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"],
"ignoreDependencies": ["cordis"]
},
"packages/core/agent-loop": { "packages/core/agent-loop": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"] "project": ["src/**/*.ts", "tests/**/*.ts"]
@@ -85,6 +90,10 @@
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"] "project": ["src/**/*.ts", "tests/**/*.ts"]
}, },
"packages/ui/stdio": {
"entry": ["tests/**/*.spec.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
},
"packages/ui/jsonrpc-agent": { "packages/ui/jsonrpc-agent": {
"project": ["src/**/*.ts"] "project": ["src/**/*.ts"]
}, },

View File

@@ -28,7 +28,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | 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 family: logical corpus, surface records, and bounded exact reads | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface |
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations | | [`support/`](support/README.md) | Support infrastructure (invariants, replay, Loader smokes) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and this table). The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and this table).

View File

@@ -2,6 +2,8 @@
Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package.
## Config ## Config
```yaml ```yaml

View File

@@ -14,9 +14,6 @@ import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { DEFAULT_GRACE_MS, runBash } from './run.ts' import { DEFAULT_GRACE_MS, runBash } from './run.ts'
import type { RunInternals, RunningBash } from './run.ts' import type { RunInternals, RunningBash } from './run.ts'
export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run.ts'
export type { RunInternals, RunningBash, SpawnOutcome, SpawnSpec } from './run.ts'
/** Plugin config (all optional — `static Config` supplies the defaults). */ /** Plugin config (all optional — `static Config` supplies the defaults). */
export interface Config { export interface Config {
/** Default working directory for commands (default: process.cwd()). */ /** Default working directory for commands (default: process.cwd()). */
@@ -170,7 +167,6 @@ export class LocalBashExecutor extends BashExecutor {
const id = BashTaskId(`bash-${this.nextTaskId++}`) const id = BashTaskId(`bash-${this.nextTaskId++}`)
const task: TrackedTask = { const task: TrackedTask = {
id, id,
command: spec.command,
status: 'running', status: 'running',
exitCode: null, exitCode: null,
signal: null, signal: null,

View File

@@ -184,27 +184,6 @@ export class OutputCollector {
writeSync(this.spillFd, chunk) writeSync(this.spillFd, chunk)
} }
// TODO(snapshot-scope): `snapshot()` has one internal caller (`finalize()` at
// the bottom of this file) and `totalBytes` is read only by a test. The live
// background-poll path goes through `readFrom()`, so inline snapshot() into
// finalize() and drop or privatize the totalBytes getter.
/**
* Read the collected tail without finalizing (the final-result snapshot).
* @returns the retained tail text, the truncation flag, and the spill path when one was created.
*/
snapshot(): CollectedOutput {
return {
text: Buffer.concat(this.chunks).toString('utf8'),
truncated: this.dropped,
...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
}
}
/** Total bytes ever pushed (including bytes dropped from memory). */
get totalBytes(): number {
return this.total
}
/** /**
* Incremental read in whole-stream byte coordinates: returns everything * Incremental read in whole-stream byte coordinates: returns everything
* pushed since `fromByte`. When `fromByte` has already slid out of the * pushed since `fromByte`. When `fromByte` has already slid out of the
@@ -243,7 +222,11 @@ export class OutputCollector {
} }
this.spillFd = undefined this.spillFd = undefined
} }
return this.snapshot() return {
text: Buffer.concat(this.chunks).toString('utf8'),
truncated: this.dropped,
...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
}
} }
} }

View File

@@ -2,8 +2,8 @@ import { mkdtempSync, readFileSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { killGroup, OutputCollector, runBash } from '@deepseek-ai/dsh-bash-local' import { killGroup, OutputCollector, runBash } from '../src/run.ts'
import type { RunningBash } from '@deepseek-ai/dsh-bash-local' import type { RunningBash } from '../src/run.ts'
const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } })) const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } }))
vi.mock('node:fs', async (importOriginal) => { vi.mock('node:fs', async (importOriginal) => {
@@ -49,7 +49,7 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise<void> { async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) { while (Date.now() < deadline) {
if (running.stdout.snapshot().text.includes(expected)) return if (running.stdout.readFrom(0).text.includes(expected)) return
await new Promise(resolve => setTimeout(resolve, 20)) await new Promise(resolve => setTimeout(resolve, 20))
} }
throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`) throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`)
@@ -295,19 +295,11 @@ describe('OutputCollector', () => {
expect(third.spillPath).toBeDefined() expect(third.spillPath).toBeDefined()
}) })
it('tracks totalBytes across drops', () => {
const collector = new OutputCollector(4, 'test', spillDir)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
expect(collector.totalBytes).toBe(8)
expect(collector.finalize().text).toBe('bbbb')
})
it('contains close failures and drops the spill path', () => { it('contains close failures and drops the spill path', () => {
const collector = new OutputCollector(4, 'closefail', spillDir) const collector = new OutputCollector(4, 'closefail', spillDir)
collector.push(Buffer.from('aaaa')) collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb')) collector.push(Buffer.from('bbbb'))
expect(collector.snapshot().spillPath).toBeDefined() expect(collector.readFrom(0).spillPath).toBeDefined()
failNextClose.value = true failNextClose.value = true
let out: ReturnType<typeof collector.finalize> let out: ReturnType<typeof collector.finalize>

View File

@@ -215,7 +215,6 @@ export type BashTaskStatus = 'running' | 'completed' | 'killed'
/** A tracked background task handle. */ /** A tracked background task handle. */
export interface BashTask { export interface BashTask {
readonly id: BashTaskId readonly id: BashTaskId
readonly command: string
status: BashTaskStatus status: BashTaskStatus
/** Exit code once finished (null = killed by signal / still running). */ /** Exit code once finished (null = killed by signal / still running). */
exitCode: number | null exitCode: number | null

View File

@@ -34,7 +34,6 @@ class StubExecutor extends BashExecutor {
start(spec: BashExecSpec): BashTask { start(spec: BashExecSpec): BashTask {
const task: BashTask = { const task: BashTask = {
id: BashTaskId(`stub-${this.tasks.size + 1}`), id: BashTaskId(`stub-${this.tasks.size + 1}`),
command: spec.command,
status: 'running', status: 'running',
exitCode: null, exitCode: null,
signal: null, signal: null,

View File

@@ -4,6 +4,8 @@ The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registere
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
The package root exposes only the Cordis plugin contract (`name`, `inject`, `apply`); result rendering remains an implementation detail covered by same-package tests.
The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on. A sandboxing executor changes the `bash` schema and result markers but adds no mode statement or switch notice; see [Per-session mode](#per-session-mode-switching). The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on. A sandboxing executor changes the `bash` schema and result markers but adds no mode statement or switch notice; see [Per-session mode](#per-session-mode-switching).
## Tools ## Tools

View File

@@ -21,7 +21,8 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-user-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash' import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash' import type { BashTask } from '@deepseek-ai/dsh-bash'
import { parseExitStatus, renderResult } from './render.ts'
export const name = 'tool-bash' export const name = 'tool-bash'
export const inject = ['tools', 'bash', 'systemPrompt'] export const inject = ['tools', 'bash', 'systemPrompt']
@@ -118,65 +119,6 @@ function bashDescription(escalationModes: readonly SandboxMode[]): string {
+ 'it — but it does not forbid attempting or escalating other commands later.' + 'it — but it does not forbid attempting or escalating other commands later.'
} }
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
function streamText(output: CollectedOutput): string {
if (!output.truncated) return output.text
return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`
}
/**
* Shape one finished run into model-visible stdout, marked stderr, and status
* facts. Non-zero exits and sandbox denials remain ordinary results; only
* infrastructure failure or abort makes the tool call itself fail.
*
* @param result - the completed foreground run from the executor.
* @param escalationModes - the escalation targets this composition advertises; non-empty
* adds the same-turn escalation hint after a denial marker (default `[]`: no hint).
* @returns the model-facing text: output body (or `(no output)`), then any
* timeout/signal/exit markers, each on its own line.
*/
export function renderResult(
result: BashRunResult,
escalationModes: readonly SandboxMode[] = [],
): string {
const out = streamText(result.stdout)
const err = streamText(result.stderr)
let body = out
if (err.length > 0) {
// Single newline between sections (stdout usually ends with one already).
if (body.length > 0 && !body.endsWith('\n')) body += '\n'
body += `[stderr]\n${err}`
}
if (body.length === 0) body = '(no output)'
const markers: string[] = []
// Keep `[exit code: N]` last so parseExitStatus() can recover it. A denial,
// like a timeout, remains a reported fact for the model to handle.
if (result.sandbox?.denied) {
markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
// Add the retry hint only when the schema advertises escalation, before
// the final exit marker.
if (escalationModes.length > 0) {
markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
}
}
// Timeout is reported independently of how the process actually ended: a
// command can trap SIGTERM and exit 0 after our timer fired (e.g.
// `trap "exit 0" TERM; sleep 60`), giving timedOut:true / exitCode:0 /
// signal:null — the model must still see that the command was cut short.
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
if (result.signal !== null) {
markers.push(`[killed by signal: ${result.signal}]`)
} else if (result.exitCode !== 0) {
markers.push(`[exit code: ${result.exitCode}]`)
}
if (markers.length === 0) return body
if (!body.endsWith('\n')) body += '\n'
return body + markers.join('\n')
}
// Pure tool-owned presentation used for both live events and replay. // Pure tool-owned presentation used for both live events and replay.
/** /**
@@ -224,18 +166,6 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView |
return { card: 'terminal', output: raw, ...parseExitStatus(raw) } return { card: 'terminal', output: raw, ...parseExitStatus(raw) }
} }
/**
* Recover exit status from the final marked line emitted by {@link renderResult}.
* A program whose own final line exactly mimics a marker remains ambiguous for UI display.
*/
function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
if (signal?.[1] !== undefined) return { signal: signal[1] }
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
return { exitCode: 0 }
}
/** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */ /** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */
function presentTaskCall(verb: string, args: { task_id: string }): GenericCallView { function presentTaskCall(verb: string, args: { task_id: string }): GenericCallView {
return { card: 'generic', title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id } return { card: 'generic', title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id }

View File

@@ -0,0 +1,92 @@
/**
* Model-facing result rendering for the bash tool.
*
* @module @deepseek-ai/dsh-tool-bash/render
*/
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
function streamText(output: CollectedOutput): string {
if (!output.truncated) return output.text
return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`
}
/**
* Shape one finished run into the text the model sees: stdout, then a marked
* stderr section, then exit-status markers. Non-zero exits are REPORTED, not
* errored — the model decides how to react; only infrastructure failures
* (spawn errors, aborts) surface as isError results.
* @param result - the completed foreground run from the executor.
* @param escalationModes - the escalation targets this composition advertises;
* non-empty adds the same-turn escalation hint after a denial marker
* (default `[]`: no hint).
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
*/
export function renderResult(
result: BashRunResult,
escalationModes: readonly SandboxMode[] = [],
): string {
const out = streamText(result.stdout)
const err = streamText(result.stderr)
let body = out
if (err.length > 0) {
// Single newline between sections (stdout usually ends with one already).
if (body.length > 0 && !body.endsWith('\n')) body += '\n'
body += `[stderr]\n${err}`
}
if (body.length === 0) body = '(no output)'
const markers: string[] = []
// The sandbox marker precedes the exit-status markers so `[exit code: N]`
// stays the LAST line (exitStatus() anchors its parse there). Denial is a
// reported fact like timeout: the model decides how to react.
if (result.sandbox?.denied) {
markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
// The same-turn nudge lives at the decision point: only when this
// composition advertises the fields (a lever is never hinted that the
// schema does not offer), and inside the sandbox marker family so the
// exit-code marker stays the last line.
if (escalationModes.length > 0) {
markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
}
}
// Timeout is reported independently of how the process actually ended: a
// command can trap SIGTERM and exit 0 after our timer fired (e.g.
// `trap "exit 0" TERM; sleep 60`), giving timedOut:true / exitCode:0 /
// signal:null — the model must still see that the command was cut short.
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
if (result.signal !== null) {
markers.push(`[killed by signal: ${result.signal}]`)
} else if (result.exitCode !== 0) {
markers.push(`[exit code: ${result.exitCode}]`)
}
if (markers.length === 0) return body
if (!body.endsWith('\n')) body += '\n'
return body + markers.join('\n')
}
/**
* Recover the structured exit status from a rendered {@link renderResult}
* string — the inverse of the status markers it appends. A killed marker
* yields `signal`; otherwise a non-zero marker yields `exitCode`; absent both
* means a clean exit 0.
*
* Replay only retains the rendered content text, not the original
* `BashRunResult`, so terminal presentation must recover the exit pill here.
* Requiring a leading newline and the end of the string keeps ordinary output
* that merely ends with marker-like text from matching unless the final line
* is indistinguishable from a real marker.
* @param text - rendered model-facing bash result.
* @returns the recovered terminal exit code or signal.
*/
export function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
if (signal?.[1] !== undefined) return { signal: signal[1] }
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
return { exitCode: 0 }
}

View File

@@ -19,7 +19,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import ApprovalService from '@deepseek-ai/dsh-user-approval' import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { renderResult } from '@deepseek-ai/dsh-tool-bash' import { renderResult } from '../src/render.ts'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-')) const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
@@ -114,7 +114,6 @@ abstract class TestBashExecutor extends BashExecutor {
class LossyReadBashExecutor extends TestBashExecutor { class LossyReadBashExecutor extends TestBashExecutor {
private readonly task: BashTask = { private readonly task: BashTask = {
id: BashTaskId('bash-lossy'), id: BashTaskId('bash-lossy'),
command: 'fake',
status: 'running', status: 'running',
exitCode: null, exitCode: null,
signal: null, signal: null,
@@ -1034,7 +1033,6 @@ describe('sandbox rendering', () => {
class FactsOnlyExecutor extends TestBashExecutor { class FactsOnlyExecutor extends TestBashExecutor {
private readonly task: BashTask = { private readonly task: BashTask = {
id: BashTaskId('bash-facts'), id: BashTaskId('bash-facts'),
command: 'fake',
status: 'completed', status: 'completed',
exitCode: 1, exitCode: 1,
signal: null, signal: null,

View File

@@ -247,8 +247,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [ methods: [
'registerSearchProvider(provider: WebSearchProvider): () => void', 'registerSearchProvider(provider: WebSearchProvider): () => void',
'registerFetchProvider(provider: WebFetchProvider): () => void', 'registerFetchProvider(provider: WebFetchProvider): () => void',
'async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>', 'async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>',
'async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>', 'async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>',
], ],
}, },
{ {
@@ -582,7 +582,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
}, },
{ {
name: 'BashTask', name: 'BashTask',
declaration: 'export interface BashTask {\n readonly id: BashTaskId;\n readonly command: string;\n status: BashTaskStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise<void>;\n sandbox?: BashSandboxInfo;\n}', declaration: 'export interface BashTask {\n readonly id: BashTaskId;\n status: BashTaskStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise<void>;\n sandbox?: BashSandboxInfo;\n}',
}, },
{ {
name: 'BashTaskId', name: 'BashTaskId',
@@ -1014,7 +1014,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
}, },
{ {
name: 'ToolExecutionResult', name: 'ToolExecutionResult',
declaration: 'export interface ToolExecutionResult {\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContext?: HookContext;\n meta?: unknown;\n}', declaration: 'export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContext?: HookContext;\n meta?: unknown;\n}',
}, },
{ {
name: 'ToolExecutionToken', name: 'ToolExecutionToken',
@@ -1068,33 +1068,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'UserInteractionProvider', name: 'UserInteractionProvider',
declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>;\n}', declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>;\n}',
}, },
{
name: 'WebExecContext',
declaration: 'export interface WebExecContext {\n readonly signal?: AbortSignal;\n}',
},
{ {
name: 'WebFetchBody', name: 'WebFetchBody',
declaration: 'export type WebFetchBody = {\n readonly kind: \'html\';\n readonly content: string;\n} | {\n readonly kind: \'text\';\n readonly content: string;\n};', declaration: 'export type WebFetchBody = {\n readonly kind: \'html\';\n readonly content: string;\n} | {\n readonly kind: \'text\';\n readonly content: string;\n};',
}, },
{ {
name: 'WebFetchProvider', name: 'WebFetchProvider',
declaration: 'export interface WebFetchProvider {\n readonly id: string;\n status(): WebProviderStatus;\n fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>;\n}', declaration: 'export interface WebFetchProvider {\n readonly id: string;\n available(): boolean;\n fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>;\n}',
}, },
{ {
name: 'WebFetchRequest', name: 'WebFetchRequest',
declaration: 'export interface WebFetchRequest {\n readonly url: string;\n readonly timeoutMs?: number;\n}', declaration: 'export interface WebFetchRequest {\n readonly url: string;\n}',
}, },
{ {
name: 'WebFetchResult', name: 'WebFetchResult',
declaration: 'export interface WebFetchResult {\n readonly providerId: string;\n readonly url: string;\n readonly statusCode: number;\n readonly body: WebFetchBody;\n readonly truncated: boolean;\n}', declaration: 'export interface WebFetchResult {\n readonly url: string;\n readonly statusCode: number;\n readonly body: WebFetchBody;\n readonly truncated: boolean;\n}',
},
{
name: 'WebProviderStatus',
declaration: 'export type WebProviderStatus = {\n readonly available: true;\n} | {\n readonly available: false;\n readonly reason: \'missing-credential\' | \'misconfigured\';\n};',
}, },
{ {
name: 'WebSearchProvider', name: 'WebSearchProvider',
declaration: 'export interface WebSearchProvider {\n readonly id: string;\n status(): WebProviderStatus;\n search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>;\n}', declaration: 'export interface WebSearchProvider {\n readonly id: string;\n available(): boolean;\n search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;\n}',
}, },
{ {
name: 'WebSearchRequest', name: 'WebSearchRequest',
@@ -1102,7 +1094,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
}, },
{ {
name: 'WebSearchResult', name: 'WebSearchResult',
declaration: 'export interface WebSearchResult {\n readonly providerId: string;\n readonly query: string;\n readonly content?: string;\n readonly sources: readonly WebSearchSource[];\n readonly truncated: boolean;\n}', declaration: 'export interface WebSearchResult {\n readonly content?: string;\n readonly sources: readonly WebSearchSource[];\n readonly truncated: boolean;\n}',
}, },
{ {
name: 'WebSearchSource', name: 'WebSearchSource',

View File

@@ -574,7 +574,8 @@ async function runStep(
}) })
session.append('tool/result', { session.append('tool/result', {
turn, step, turn, step,
// Preserve transcript pairing even if a post-execute listener returns another id. // Correlation comes from the immutable execution input; the result does
// not duplicate this authoritative transcript identity.
callId: call.id, callId: call.id,
content: result.content, content: result.content,
isError: result.isError, isError: result.isError,

View File

@@ -1109,8 +1109,7 @@ describe('tool result call identity', () => {
// A post-execute listener transforms the result (accept-with-replacement). // A post-execute listener transforms the result (accept-with-replacement).
// The loop must still record the tool/result under the model's authoritative // The loop must still record the tool/result under the model's authoritative
// call.id (the loop ignores result.callId — which the registry always sets to // call.id, which is the immutable identity carried by the execution input.
// exec.callId anyway — and uses call.id, the model-transcript id).
ctx.on('tools/post-execute', (exec, _result) => { ctx.on('tools/post-execute', (exec, _result) => {
expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] }) return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
@@ -1120,8 +1119,7 @@ describe('tool result call identity', () => {
send(agent, 'use tool') send(agent, 'use tool')
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
// The logged tool/result.callId is the originating call.id, NOT the // The logged tool/result.callId is the originating call.id.
// listener's wrong id.
const resultEvent = [...agent.session.events].find(e => e.type === 'tool/result') const resultEvent = [...agent.session.events].find(e => e.type === 'tool/result')
expect(resultEvent?.type).toBe('tool/result') expect(resultEvent?.type).toBe('tool/result')
if (resultEvent?.type === 'tool/result') { if (resultEvent?.type === 'tool/result') {

View File

@@ -226,7 +226,7 @@ export class SystemPrompt extends Service {
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>() private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
private readonly toolOrder: string[] | undefined private readonly toolOrder: string[] | undefined
constructor(ctx: Context, public config: Config) { constructor(ctx: Context, config: Config) {
super(ctx, 'systemPrompt') super(ctx, 'systemPrompt')
this.toolOrder = validateToolOrder(config.toolOrder) this.toolOrder = validateToolOrder(config.toolOrder)
// Keep harness-owned openers independent of the selected loop plugin. // Keep harness-owned openers independent of the selected loop plugin.

View File

@@ -36,7 +36,7 @@ The live registry pipeline has three transformable waterfalls followed by the ob
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token. - `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. - `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. - `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContext?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text.
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny. - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
- `PostToolDecision` — `{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns. - `PostToolDecision` — `{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
- `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch. - `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.

View File

@@ -220,7 +220,7 @@ export interface ToolErrorInfo {
* distinguish it from a tool body's own error. * distinguish it from a tool body's own error.
*/ */
export class ToolNotFoundError extends HarnessError { export class ToolNotFoundError extends HarnessError {
constructor(public readonly toolName: string) { constructor(toolName: string) {
super(`unknown tool "${toolName}"`, 'UNKNOWN_TOOL') super(`unknown tool "${toolName}"`, 'UNKNOWN_TOOL')
this.name = 'ToolNotFoundError' this.name = 'ToolNotFoundError'
} }
@@ -228,7 +228,6 @@ export class ToolNotFoundError extends HarnessError {
/** The outcome of one tool call. */ /** The outcome of one tool call. */
export interface ToolExecutionResult { export interface ToolExecutionResult {
callId: CallId
content: ContentBlock[] content: ContentBlock[]
isError: boolean isError: boolean
/** /**
@@ -704,7 +703,7 @@ export class ToolRegistry extends Service {
} }
} catch (error: unknown) { } catch (error: unknown) {
execution = { ...base, arguments: undefined } execution = { ...base, arguments: undefined }
const result = this.materializeFinalResult(toolErrorResult(callId, error)) const result = this.materializeFinalResult(toolErrorResult(error))
this.notifyResult(execution, result) this.notifyResult(execution, result)
return result return result
} }
@@ -714,7 +713,7 @@ export class ToolRegistry extends Service {
} catch (error: unknown) { } catch (error: unknown) {
// Outer backstop: a throwing pre/post-execute listener, guard, or the // Outer backstop: a throwing pre/post-execute listener, guard, or the
// waterfall machinery becomes an isError result, never a turn failure. // waterfall machinery becomes an isError result, never a turn failure.
result = this.materializeFinalResult(toolErrorResult(execution.callId, error)) result = this.materializeFinalResult(toolErrorResult(error))
} }
this.notifyResult(execution, result) this.notifyResult(execution, result)
return result return result
@@ -739,7 +738,6 @@ export class ToolRegistry extends Service {
// Every non-grant, including a failed/unavailable approval request, takes // Every non-grant, including a failed/unavailable approval request, takes
// the same deny path and still reaches post-policy plus result observers. // the same deny path and still reaches post-policy plus result observers.
const denied: ToolExecutionResult = { const denied: ToolExecutionResult = {
callId: exec.callId,
content: [{ type: 'text', text: `Error: ${denialReason}` }], content: [{ type: 'text', text: `Error: ${denialReason}` }],
isError: true, isError: true,
} }
@@ -770,16 +768,12 @@ export class ToolRegistry extends Service {
const returned = await tool.execute(exec.arguments, exec) const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta const meta = Array.isArray(returned) ? undefined : returned.meta
return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} } return { content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) { } catch (error: unknown) {
return toolErrorResult(exec.callId, error) return toolErrorResult(error)
} }
}, },
) )
if (result.callId !== exec.callId) {
throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`)
}
return await this.postExecute(exec, result) return await this.postExecute(exec, result)
} }
@@ -854,7 +848,6 @@ export class ToolRegistry extends Service {
const additionalContext = decision.additionalContext const additionalContext = decision.additionalContext
if (decision.kind === 'block') { if (decision.kind === 'block') {
return { return {
callId: result.callId,
content: decision.feedback, content: decision.feedback,
isError: true, isError: true,
...additionalContext ? { additionalContext } : {}, ...additionalContext ? { additionalContext } : {},
@@ -883,10 +876,9 @@ function createExecutionToken(): ToolExecutionToken {
return Symbol('dsh.tool.execution') as ToolExecutionToken return Symbol('dsh.tool.execution') as ToolExecutionToken
} }
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult { function toolErrorResult(error: unknown): ToolExecutionResult {
const info = errorInfo(error) const info = errorInfo(error)
return { return {
callId,
content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }], content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
isError: true, isError: true,
...info ? { error: info } : {}, ...info ? { error: info } : {},

View File

@@ -548,7 +548,6 @@ describe('scoped execution dispatch', () => {
expect(reads).toBe(1) expect(reads).toBe(1)
expect(result).toEqual({ expect(result).toEqual({
callId: CallId('unstable-arguments'),
content: [{ type: 'text', text: 'ran:t' }], content: [{ type: 'text', text: 'ran:t' }],
isError: false, isError: false,
}) })
@@ -564,10 +563,9 @@ describe('scoped execution dispatch', () => {
ctx.on('internal/dispatch', (mode, name) => { ctx.on('internal/dispatch', (mode, name) => {
if (name === 'tools/result') dispatchModes.push(mode) if (name === 'tools/result') dispatchModes.push(mode)
}) })
ctx.on('tools/execute', async (exec, next) => { ctx.on('tools/execute', async (_exec, next) => {
await next() await next()
return { return {
callId: exec.callId,
content: [{ type: 'text', text: 'outer failure' }], content: [{ type: 'text', text: 'outer failure' }],
isError: true, isError: true,
} }

View File

@@ -80,7 +80,7 @@ describe('ToolRegistry', () => {
const ctx = await setup() const ctx = await setup()
ctx.tools.register(echoTool) ctx.tools.register(echoTool)
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false }) expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
}) })
it('threads a tool-attached meta (object return form) onto the result', async () => { it('threads a tool-attached meta (object return form) onto the result', async () => {
@@ -94,7 +94,6 @@ describe('ToolRegistry', () => {
}) })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} })
expect(result).toEqual({ expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'ok' }], content: [{ type: 'text', text: 'ok' }],
isError: false, isError: false,
meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] }, meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] },
@@ -111,7 +110,7 @@ describe('ToolRegistry', () => {
}, },
}) })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }) expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false })
expect('meta' in result).toBe(false) expect('meta' in result).toBe(false)
}) })
@@ -178,13 +177,12 @@ describe('ToolRegistry', () => {
}) })
}) })
it('ToolNotFoundError carries the tool name and a stable code', async () => { it('ToolNotFoundError carries a stable message and code', async () => {
const { HarnessError } = await import('@deepseek-ai/dsh-llm') const { HarnessError } = await import('@deepseek-ai/dsh-llm')
const err = new ToolNotFoundError('ghost') const err = new ToolNotFoundError('ghost')
expect(err).toBeInstanceOf(HarnessError) expect(err).toBeInstanceOf(HarnessError)
expect(err.name).toBe('ToolNotFoundError') expect(err.name).toBe('ToolNotFoundError')
expect(err.code).toBe('UNKNOWN_TOOL') expect(err.code).toBe('UNKNOWN_TOOL')
expect(err.toolName).toBe('ghost')
expect(err.message).toBe('unknown tool "ghost"') expect(err.message).toBe('unknown tool "ghost"')
}) })
@@ -425,7 +423,7 @@ describe('ToolRegistry', () => {
ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() }) ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false }) expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
// The around seam wraps dispatch; pre gates before it, post runs over its result. // The around seam wraps dispatch; pre gates before it, post runs over its result.
expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post']) expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
}) })
@@ -526,8 +524,8 @@ describe('ToolRegistry', () => {
async execute() { dispatched = true; return [] }, async execute() { dispatched = true; return [] },
}) })
ctx.on('tools/execute', async (exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
({ callId: exec.callId, content: [{ type: 'text', text: 'short-circuited' }], isError: false })) ({ content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} })
expect(dispatched).toBe(false) // returning without next() skips core dispatch expect(dispatched).toBe(false) // returning without next() skips core dispatch
@@ -537,8 +535,7 @@ describe('ToolRegistry', () => {
it('preserves additionalContext supplied by an around-dispatch result', async () => { it('preserves additionalContext supplied by an around-dispatch result', async () => {
const ctx = await setup() const ctx = await setup()
ctx.tools.register(echoTool) ctx.tools.register(echoTool)
ctx.on('tools/execute', async exec => ({ ctx.on('tools/execute', async () => ({
callId: exec.callId,
content: [{ type: 'text', text: 'short-circuited with context' }], content: [{ type: 'text', text: 'short-circuited with context' }],
isError: false, isError: false,
additionalContext: { additionalContext: {
@@ -556,20 +553,6 @@ describe('ToolRegistry', () => {
}) })
}) })
it('normalizes a tools/execute result with the wrong call id', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => ({ callId: CallId('other'), content: [], isError: false }))
const result = await ctx.tools.execute({
callId: CallId('malformed-shape'), name: 'echo', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({
text: 'Error: tools/execute returned callId "other" for authoritative call "malformed-shape"',
})
})
it('returns an isError result when a tools/execute listener throws', async () => { it('returns an isError result when a tools/execute listener throws', async () => {
const ctx = await setup() const ctx = await setup()
ctx.tools.register(echoTool) ctx.tools.register(echoTool)
@@ -577,7 +560,6 @@ describe('ToolRegistry', () => {
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({ expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: wrapper broke' }], content: [{ type: 'text', text: 'Error: wrapper broke' }],
isError: true, isError: true,
}) })
@@ -593,7 +575,6 @@ describe('ToolRegistry', () => {
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({ expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: permission hook broke' }], content: [{ type: 'text', text: 'Error: permission hook broke' }],
isError: true, isError: true,
}) })
@@ -609,7 +590,6 @@ describe('ToolRegistry', () => {
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({ expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: post hook broke' }], content: [{ type: 'text', text: 'Error: post hook broke' }],
isError: true, isError: true,
}) })
@@ -625,7 +605,6 @@ describe('ToolRegistry', () => {
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toMatchObject({ expect(result).toMatchObject({
callId: CallId('c1'),
isError: true, isError: true,
error: { name: 'HarnessError', code: 'DENIED' }, error: { name: 'HarnessError', code: 'DENIED' },
}) })
@@ -1254,7 +1233,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
}, },
})) }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'read /x' }], isError: false }) expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false })
}) })
it('ToolArgsError carries a stable code and the violation list', () => { it('ToolArgsError carries a stable code and the violation list', () => {

View File

@@ -6,7 +6,8 @@ Packages that exist to serve development, testing, and the examples rather than
|---|---|---| |---|---|---|
| `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) | | `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) |
| `invariants/` | Runtime event-contract assertions for development diagnostics | (listens on `session/*`, `agent/*`) | | `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`) | | `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`) | | `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-core` 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 so every example's suite is a scenario table over one shared, gate-covered implementation. `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-core` 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.

View File

@@ -859,9 +859,9 @@ describe('scoped-dispatch invariants', () => {
['agent/error', [agent, 1, 0, new Error('x')]], ['agent/error', [agent, 1, 0, new Error('x')]],
['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]],
['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]], ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ callId: 'c', content: [], isError: false })]], ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]],
['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }]], ['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }]],
] ]
for (const [event, args] of rows) { for (const [event, args] of rows) {
const subject = agent const subject = agent

View File

@@ -0,0 +1,17 @@
# `@deepseek-ai/dsh-loader-smoke`
Shared subprocess harness for keyless example smokes that boot the real stdio-agent bin and a real `cordis.yml` through the Cordis Loader. A test supplies absolute bin/config/tsconfig paths, optional environment overrides, and stdin lines; `runLoaderSmoke` owns the isolated cwd, DSH homes, tsx path resolution, 30-second process deadline, captured diagnostics, forced kill, EOF, and cleanup.
Successful runs return stdout and stderr only after a zero exit. Non-zero exits and deadlines reject with both captured streams. `LOADER_SMOKE_TEST_TIMEOUT_MS` leaves Vitest enough room for the process-owned diagnostic timeout to fire first.
This is support-tier test infrastructure, not product API. The consumers are the Loader-path smokes under `examples/{echo-agent,coding-agent,cordis-agent}`.
## Model Experience
None, as this test-only harness boots example processes and inspects their streams without changing an assembled model request.
## Known Limitations and Deferred Work
- **Only the unbuilt tsx/Loader path is exercised** — built-bin artifacts remain the responsibility of their separate e2e smokes.
- **Captured stdout and stderr are unbounded** — a runaway child can consume memory until the deadline kills it.
- **Timeout kills only the direct child** — a process tree spawned by a faulty fixture can outlive the smoke and needs external cleanup.

View File

@@ -0,0 +1,33 @@
{
"name": "@deepseek-ai/dsh-loader-smoke",
"description": "Shared subprocess harness for keyless real-Loader example smoke tests",
"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": {
"tsx": "^4.22.4"
},
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,117 @@
/**
* Shared subprocess harness for keyless example smokes that boot a real
* `cordis.yml` through the stdio-agent bin and Cordis Loader.
*
* @module @deepseek-ai/dsh-loader-smoke
*/
import { spawn } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const DEFAULT_PROCESS_TIMEOUT_MS = 30_000
const TSX_LOADER = fileURLToPath(import.meta.resolve('tsx'))
/** Vitest deadline that leaves room for the subprocess-owned 30-second diagnostic timeout. */
export const LOADER_SMOKE_TEST_TIMEOUT_MS = DEFAULT_PROCESS_TIMEOUT_MS + 15_000
/** Inputs that vary between real-Loader example smokes. */
export interface LoaderSmokeOptions {
/** Human-readable example name used in failure diagnostics. */
readonly label: string
/** Prefix for the isolated temporary process cwd. */
readonly tempDirPrefix: string
/** Absolute stdio-agent bin path. */
readonly binScript: string
/** Absolute real Loader config path. */
readonly configPath: string
/** Absolute repo tsconfig path used for unbuilt workspace-package resolution. */
readonly tsconfigPath: string
/** Environment overrides layered over the parent and isolated DSH homes. */
readonly env?: Readonly<NodeJS.ProcessEnv>
/** Lines written to stdin before EOF; omitted means immediate EOF. */
readonly stdinLines?: readonly string[]
/** Process deadline override for harness tests. */
readonly processTimeoutMs?: number
}
/** Captured output from a Loader smoke that exited successfully. */
export interface LoaderSmokeResult {
/** Complete stdout after clean exit. */
readonly stdout: string
/** Complete stderr after clean exit. */
readonly stderr: string
}
/**
* Boot one real Loader tree from an isolated cwd, write the requested stdin
* script, close stdin, and await a clean exit. The helper owns process kill and
* temp-directory cleanup on every outcome.
* @param options - example paths, environment, stdin, and diagnostic identity.
* @returns captured stdout and stderr after a zero exit.
*/
export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<LoaderSmokeResult> {
const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
const processTimeoutMs = options.processTimeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS
try {
return await new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
['--expose-internals', '--import', TSX_LOADER, options.binScript, options.configPath],
{
cwd,
env: {
...process.env,
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
...options.env,
TSX_TSCONFIG_PATH: options.tsconfigPath,
},
stdio: ['pipe', 'pipe', 'pipe'],
},
)
let stdout = ''
let stderr = ''
let deferredFailure: Error | undefined
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => { stdout += chunk })
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => { stderr += chunk })
const timer = setTimeout(() => {
deferredFailure = new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)
child.kill('SIGKILL')
}, processTimeoutMs)
child.once('exit', (code) => {
clearTimeout(timer)
if (deferredFailure !== undefined) {
reject(deferredFailure)
} else if (code === 0) {
resolve({ stdout, stderr })
} else {
reject(new Error(`${options.label} exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
}
})
// process.execPath and a just-created pipe make these OS-error paths
// impractical to induce without replacing the boundary under test.
/* v8 ignore start */
child.once('error', (error) => {
clearTimeout(timer)
reject(new Error(`${options.label} failed to start: ${error.message}`))
})
child.stdin.once('error', (error) => {
deferredFailure ??= new Error(`${options.label} stdin failed: ${error.message}`)
child.kill('SIGKILL')
})
/* v8 ignore stop */
child.stdin.end((options.stdinLines ?? []).map(line => `${line}\n`).join(''))
})
} finally {
await rm(cwd, { recursive: true, force: true })
}
}

View File

@@ -0,0 +1,4 @@
/** Non-zero subprocess fixture for the Loader-smoke harness. */
console.error('fixture failed')
process.exitCode = 7

View File

@@ -0,0 +1,4 @@
/** Deadline subprocess fixture for the Loader-smoke harness. */
console.log('fixture hanging')
setInterval(() => {}, 1_000)

View File

@@ -0,0 +1,16 @@
/** Successful subprocess fixture for the Loader-smoke harness. */
let input = ''
process.stdin.setEncoding('utf8')
process.stdin.on('data', (chunk: string) => { input += chunk })
process.stdin.on('end', () => {
console.log(JSON.stringify({
configPath: process.argv[2],
cwd: process.cwd(),
dshHome: process.env.DSH_HOME,
agentsHome: process.env.DSH_AGENTS_HOME,
marker: process.env.LOADER_SMOKE_MARKER,
input,
}))
console.error('fixture stderr')
})

View File

@@ -0,0 +1,61 @@
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
const configPath = '/tmp/fixture.cordis.yml'
const tsconfigPath = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
const fixture = (name: string): string => fileURLToPath(new URL(`./fixtures/${name}.ts`, import.meta.url))
const canonicalTempPath = (path: string): string => path.replace(/^\/private(?=\/var\/)/, '')
describe('runLoaderSmoke', () => {
it('isolates the process, writes stdin, captures output, and removes the cwd', async () => {
const result = await runLoaderSmoke({
label: 'success fixture',
tempDirPrefix: 'loader-smoke-success-',
binScript: fixture('success'),
configPath,
tsconfigPath,
env: { LOADER_SMOKE_MARKER: 'present' },
stdinLines: ['one', 'two'],
})
const output = JSON.parse(result.stdout) as {
configPath: string
cwd: string
dshHome: string
agentsHome: string
marker: string
input: string
}
expect(output).toMatchObject({
configPath,
marker: 'present',
input: 'one\ntwo\n',
})
expect(canonicalTempPath(output.dshHome)).toBe(`${canonicalTempPath(output.cwd)}/.dsh`)
expect(canonicalTempPath(output.agentsHome)).toBe(`${canonicalTempPath(output.cwd)}/.agents`)
expect(result.stderr).toContain('fixture stderr')
expect(existsSync(output.cwd)).toBe(false)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('rejects a non-zero exit with captured diagnostics', async () => {
await expect(runLoaderSmoke({
label: 'failure fixture',
tempDirPrefix: 'loader-smoke-fail-',
binScript: fixture('fail'),
configPath,
tsconfigPath,
})).rejects.toThrow('failure fixture exited 7. stdout:\n\nstderr:\nfixture failed')
})
it('kills a process at its deadline and reports captured output', async () => {
await expect(runLoaderSmoke({
label: 'hanging fixture',
tempDirPrefix: 'loader-smoke-hang-',
binScript: fixture('hang'),
configPath,
tsconfigPath,
processTimeoutMs: 100,
})).rejects.toThrow('hanging fixture did not exit within 0.1s.')
})
})

View File

@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": []
}

View File

@@ -6,7 +6,6 @@
*/ */
import type { Context } from 'cordis' import type { Context } from 'cordis'
import type { CallId } from '@deepseek-ai/dsh-llm'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
@@ -30,13 +29,11 @@ export const inject = ['tools']
* is the model-facing message; `error.code` is the same {@link TOOL_TIMEOUT} * is the model-facing message; `error.code` is the same {@link TOOL_TIMEOUT}
* this plugin owns, so a retry/sandbox plugin (and replay) can route on it. * this plugin owns, so a retry/sandbox plugin (and replay) can route on it.
* *
* @param callId - the timed-out call's id, carried onto the replacement result.
* @param timeoutMs - the elapsed budget, rendered into the model-facing message. * @param timeoutMs - the elapsed budget, rendered into the model-facing message.
* @returns the `isError` {@link ToolExecutionResult} with a `TOOL_TIMEOUT` error. * @returns the `isError` {@link ToolExecutionResult} with a `TOOL_TIMEOUT` error.
*/ */
export function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecutionResult { function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
return { return {
callId,
content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }], content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }],
isError: true, isError: true,
error: { name: 'ToolTimeoutError', code: TOOL_TIMEOUT }, error: { name: 'ToolTimeoutError', code: TOOL_TIMEOUT },
@@ -68,7 +65,7 @@ export function apply(ctx: Context): void {
// quiescence; replace whatever it returned (its own abort result) with the // quiescence; replace whatever it returned (its own abort result) with the
// structured TOOL_TIMEOUT the model sees. // structured TOOL_TIMEOUT the model sees.
if (timeoutOf(d.signal, TOOL_TIMEOUT) !== undefined) { if (timeoutOf(d.signal, TOOL_TIMEOUT) !== undefined) {
return toolTimeoutResult(exec.callId, timeoutMs) return toolTimeoutResult(timeoutMs)
} }
return result return result
} finally { } finally {

View File

@@ -11,9 +11,9 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader' import Loader from '@cordisjs/plugin-loader'
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type ToolExecutionInput, type ToolExecutionResult, type PostToolDecision } from '@deepseek-ai/dsh-tools' import ToolRegistry, { defineTool, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy' import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
import { TOOL_TIMEOUT, toolTimeoutResult } from '@deepseek-ai/dsh-timeout-policy' import { TOOL_TIMEOUT } from '@deepseek-ai/dsh-timeout-policy'
/** Mount the registry + the zero-config timeout-policy enforcer. */ /** Mount the registry + the zero-config timeout-policy enforcer. */
async function setup() { async function setup() {
@@ -60,7 +60,7 @@ describe('timeout-policy delegation (unconfigured / fast)', () => {
ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000, ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] } })) async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }) expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false })
}) })
it('a budgeted tool receives the DERIVED deadline signal (not the caller signal) during dispatch', async () => { it('a budgeted tool receives the DERIVED deadline signal (not the caller signal) during dispatch', async () => {
@@ -109,7 +109,6 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
await vi.advanceTimersByTimeAsync(150) await vi.advanceTimersByTimeAsync(150)
const result = await pending const result = await pending
expect(result).toEqual({ expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: tool call timed out after 100ms' }], content: [{ type: 'text', text: 'Error: tool call timed out after 100ms' }],
isError: true, isError: true,
error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
@@ -140,16 +139,7 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
}) })
}) })
describe('toolTimeoutResult', () => { describe('timeout-policy contract', () => {
it('builds the structured TOOL_TIMEOUT result', () => {
expect(toolTimeoutResult(CallId('c9'), 250)).toEqual({
callId: CallId('c9'),
content: [{ type: 'text', text: 'Error: tool call timed out after 250ms' }],
isError: true,
error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
} satisfies ToolExecutionResult)
})
it('exposes the owned code constant', () => { it('exposes the owned code constant', () => {
expect(TOOL_TIMEOUT).toBe('TOOL_TIMEOUT') expect(TOOL_TIMEOUT).toBe('TOOL_TIMEOUT')
}) })

View File

@@ -9,13 +9,14 @@ Integrations that expose the agent to an external editor or client. These are **
| `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` | | `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` | | `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) | | `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
| `stdio/` | Terminal readline channel over `ctx.agents`, `session/event`, and `ctx.userInteraction`; agent lifecycle stays with app/developer code | (drives `ctx.agents`) |
| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) | | `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
| `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | | `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
| `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) | | `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) |
| `jsonrpc-agent/` | Bin-only SDK runtime app that boots an external `cordis.yml` | (`bin` only) | | `jsonrpc-agent/` | Bin-only SDK runtime app that boots an external `cordis.yml` | (`bin` only) |
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
A UI integration is a client-driver plugin, not a loop change or capability seam: it consumes the existing `agent/*` events and `dsh-agent` factory. `jsonrpc` is the SDK-client sibling of the `acp` editor bridge. The readline UI lives inside [`stdio-agent/`](stdio-agent/README.md) because it is scaffolding for that front door, not an independently swappable integration. A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) plugin is the unstructured readline analogue of the `acp` bridge; app bundles and SDK projects compose it explicitly with the services and tools their product profile selects.
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers. `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.

View File

@@ -15,7 +15,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
| `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent | | `@deepseek-ai/dsh-stdio` | the readline UI, bound to the `main` agent |
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. `@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.

View File

@@ -38,9 +38,10 @@
"@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-agent-core": "^0.0.1", "@deepseek-ai/dsh-agent-core": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-stdio": "^0.0.1",
"@deepseek-ai/dsh-tool-ask-user": "^0.0.1", "@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.7", "cordis": "^4.0.0-rc.7",
"schemastery": "^3.17.0" "schemastery": "^3.17.0"
@@ -55,9 +56,10 @@
"@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-agent-core": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-stdio": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.7", "cordis": "^4.0.0-rc.7",
"schemastery": "^3.17.0" "schemastery": "^3.17.0"

View File

@@ -1,8 +1,8 @@
/** /**
* The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-core}) plus the * The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-core}) plus the
* coupled front-door cluster a terminal chat needs — a console logger, the readline UI (the * coupled front-door cluster a terminal chat needs — a console logger, the independently
* in-package `stdio-chat` module), JSONL session persistence, and a pre-created `main` agent * packaged readline UI, JSONL session persistence, the user-interaction seam with its
* the UI drives. * `ask_user_question` tool, and a pre-created `main` agent the UI drives.
* Swappable adapters, executors, optional tools, and HMR stay in the leaf. This * Swappable adapters, executors, optional tools, and HMR stay in the leaf. This
* Loader plugin intentionally exposes named exports only; a default export * Loader plugin intentionally exposes named exports only; a default export
* would hide its `Config` schema (see docs/postmortem/0001). * would hide its `Config` schema (see docs/postmortem/0001).
@@ -19,7 +19,7 @@ import * as agentCore from '@deepseek-ai/dsh-agent-core'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as uiStdio from './stdio-chat.ts' import * as uiStdio from '@deepseek-ai/dsh-stdio'
export const name = 'stdio-agent' export const name = 'stdio-agent'

View File

@@ -24,6 +24,7 @@ const dshPackages = [
'bash/tool-bash', 'support/invariants', 'ui/app-boot', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence', 'session-persistence/session-persistence',
'session-persistence/session-persistence-jsonl', 'ui/stdio-agent', 'session-persistence/session-persistence-jsonl', 'ui/stdio-agent',
'ui/stdio', 'ui/tool-ask-user', 'ui/user-interaction',
] ]
const vendorPackages = [ const vendorPackages = [
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',

View File

@@ -35,6 +35,9 @@
{ {
"path": "../user-interaction" "path": "../user-interaction"
}, },
{
"path": "../stdio"
},
{ {
"path": "../tool-ask-user" "path": "../tool-ask-user"
}, },

View File

@@ -0,0 +1,42 @@
# @deepseek-ai/dsh-stdio
The terminal readline front door for DeepSeek Harness agents. It reads prompts from stdin, sends or steers them through `ctx.agents`, renders the durable `session/event` transcript to stdout, and answers `ctx.userInteraction` requests in the same terminal.
This package owns the terminal channel only. It injects `agents` and `userInteraction`, then drives an agent created or resumed by app or developer code. The agent spine, agent lifecycle, console logger, and model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
## Config
| Key | Default | Meaning |
|---|---|---|
| `welcome` | `ready.` | Banner printed before the first prompt |
| `agent` | `main` | Agent id driven by stdin and observed for EOF shutdown |
The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. Disposal closes readline and unregisters every listener/provider through Cordis effects.
```yaml
- id: stdio
name: '@deepseek-ai/dsh-stdio'
config:
welcome: 'agent REPL ready. Give it a coding task.'
agent: main
```
## Model Experience
### Readline prompt input
**What the model sees**: Each non-empty terminal line outside an active question becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running.
**Token effect**: Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens.
### Terminal user-interaction answers
**What the model sees**: When a consumer calls `ctx.userInteraction.ask()`, this provider renders the question in the terminal and returns selected option labels or `custom` text. Through `dsh-tool-ask-user`, closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`; disposal or abort becomes `Error: ask_user_question was interrupted before the user answered`.
**Token effect**: Waiting and terminal prompts add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result.
## Known Limitations and Deferred Work
- **One configured agent receives stdin** — the session/event renderer can print output from any session, but input lines always drive the configured `agent` id rather than routing by the visible label.
- **Terminal questions are text-only and sequential** — the provider queues asks, supports option labels plus custom text, and has no richer UI shapes such as file pickers or diff previews.
- **Closed stdin ends the terminal channel** — EOF rejects active or queued questions and exits after submitted work reaches idle; there is no reconnect path for a long-lived process.

View File

@@ -0,0 +1,42 @@
{
"name": "@deepseek-ai/dsh-stdio",
"description": "Terminal readline front door for driving and rendering DeepSeek Harness agents over stdio",
"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-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -2,7 +2,11 @@
* The stdio app's readline UI: reads lines from stdin into `agent.send()` or * The stdio app's readline UI: reads lines from stdin into `agent.send()` or
* `steer()`, renders the durable event stream to stdout, and exits piped input * `steer()`, renders the durable event stream to stdout, and exits piped input
* only after submitted work reaches idle. * only after submitted work reaches idle.
* @module @deepseek-ai/dsh-stdio-agent/stdio-chat *
* This package is the independently composable stdio front door. It establishes
* the terminal channel and drives an agent created or resumed by app or
* developer code.
* @module @deepseek-ai/dsh-stdio
*/ */
import { createInterface } from 'node:readline' import { createInterface } from 'node:readline'
@@ -26,8 +30,6 @@ export const inject = ['agents', 'userInteraction']
export interface Config { export interface Config {
/** Banner printed once on start, before the first `> ` prompt. */ /** Banner printed once on start, before the first `> ` prompt. */
welcome?: string welcome?: string
// TODO(fixed-stdio-agent): this app-internal plugin is mounted only for the
// precreated `main` agent; remove configurability and its config-only test.
/** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */ /** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */
agent?: string agent?: string
} }
@@ -350,16 +352,38 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
}, 'ui-stdio') }, 'ui-stdio')
} }
/**
* Open the terminal channel once its configured agent exists. Generated stdio
* projects boot the Cordis tree first and create or resume the agent from
* developer code immediately afterward, so stdin must remain untouched until
* the matching `agent/created` notification arrives.
* @param ctx - the context supplying the agent registry and event stream.
* @param config - presentation and target-agent configuration.
* @param runtime - process-I/O seam.
*/
export function mountStdio(ctx: Context, config: Config, runtime: StdioRuntime): void {
const agentId = AgentId(config.agent ?? 'main')
if (ctx.agents.get(agentId) !== undefined) {
createStdioChat(ctx, config, runtime)
return
}
const dispose = ctx.on('agent/created', (agent) => {
if (agent.id !== agentId) return
dispose()
createStdioChat(ctx, config, runtime)
})
}
/** /**
* Cordis entry point. Binds the real `process` streams and delegates to * Cordis entry point. Binds the real `process` streams and delegates to
* {@link createStdioChat}; the indirection keeps the side-effecting handles out * {@link mountStdio}; the indirection keeps the side-effecting handles out
* of the testable core, which is why the unit suite drives `createStdioChat` * of the testable core, which is why the unit suite drives `createStdioChat`
* directly. This thin wrapper is exercised end-to-end by the keyless * directly. This thin wrapper is exercised end-to-end by the keyless
* Loader-path e2e smoke in `examples/echo-agent` (the real product entry). * Loader-path e2e smoke in `examples/echo-agent` (the real product entry).
*/ */
/* v8 ignore start -- production stdio wiring; testable core is createStdioChat() (covered), exercised e2e by echo-agent keyless smoke */ /* v8 ignore start -- production stdio wiring; testable core is createStdioChat() (covered), exercised e2e by echo-agent keyless smoke */
export function apply(ctx: Context, config: Config): void { export function apply(ctx: Context, config: Config): void {
createStdioChat(ctx, config, { mountStdio(ctx, config, {
input: process.stdin, input: process.stdin,
output: process.stdout, output: process.stdout,
exit: code => process.exit(code), exit: code => process.exit(code),

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import Loader from '@cordisjs/plugin-loader'
import * as stdio from '../src/index.ts'
/** Real Loader export-path guard for the namespace stdio plugin. */
describe('dsh-stdio plugin export shape', () => {
it('preserves name, inject, Config, and apply through Loader unwrapping', () => {
expect('default' in stdio).toBe(false)
expect(typeof stdio.apply).toBe('function')
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(stdio) as Record<string, unknown>
expect(unwrapped).toBe(stdio)
expect(unwrapped.name).toBe('ui-stdio')
expect(unwrapped.inject).toEqual(['agents', 'userInteraction'])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
})
})

View File

@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
import type { Readable, Writable } from 'node:stream' import type { Readable, Writable } from 'node:stream'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis' import type { Context } from 'cordis'
import type { StdioRuntime } from '../src/stdio-chat.ts' import type { StdioRuntime } from '../src/index.ts'
const createInterface = vi.hoisted(() => vi.fn(() => { const createInterface = vi.hoisted(() => vi.fn(() => {
const reader = new EventEmitter() as EventEmitter & { close(): void } const reader = new EventEmitter() as EventEmitter & { close(): void }
@@ -33,7 +33,7 @@ function fakeRuntime(inputIsTTY: boolean, outputIsTTY: boolean): StdioRuntime {
describe('createStdioChat readline mode', () => { describe('createStdioChat readline mode', () => {
it('enables terminal editing only when both stdio streams are TTYs', async () => { it('enables terminal editing only when both stdio streams are TTYs', async () => {
const { createStdioChat } = await import('../src/stdio-chat.ts') const { createStdioChat } = await import('../src/index.ts')
const tty = fakeRuntime(true, true) const tty = fakeRuntime(true, true)
createStdioChat(fakeContext(), {}, tty) createStdioChat(fakeContext(), {}, tty)

View File

@@ -6,7 +6,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts' import { createStdioChat, mountStdio, type Config, type StdioRuntime } from '../src/index.ts'
/** /**
* Unit tests for the stdio UI plugin. They drive the REAL plugin body * Unit tests for the stdio UI plugin. They drive the REAL plugin body
@@ -93,6 +93,55 @@ function flushExit(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 250)) return new Promise(resolve => setTimeout(resolve, 250))
} }
describe('mountStdio readiness', () => {
it('leaves stdin untouched until the configured agent is created', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const { runtime, out } = makeRuntime()
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
mountStdio(inner, CONFIG, runtime)
}, { inject: ['agents', 'userInteraction'] }))
expect(out.text()).toBe('')
ctx.agents.register(makeAgent('other'))
expect(out.text()).toBe('')
ctx.agents.register(makeAgent('main'))
expect(out.text()).toBe('hi there\n> ')
await fiber.dispose()
})
it('opens immediately when the configured agent already exists', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
ctx.agents.register(makeAgent('main'))
const { runtime, out } = makeRuntime()
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
mountStdio(inner, CONFIG, runtime)
}, { inject: ['agents', 'userInteraction'] }))
expect(out.text()).toBe('hi there\n> ')
await fiber.dispose()
})
it('waits for main when no target agent is configured', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const { runtime, out } = makeRuntime()
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
mountStdio(inner, { welcome: 'ready' }, runtime)
}, { inject: ['agents', 'userInteraction'] }))
ctx.agents.register(makeAgent('other'))
expect(out.text()).toBe('')
ctx.agents.register(makeAgent('main'))
expect(out.text()).toBe('ready\n> ')
await fiber.dispose()
})
})
describe('createStdioChat rendering', () => { describe('createStdioChat rendering', () => {
it('writes the welcome banner and prompt on start', async () => { it('writes the welcome banner and prompt on start', async () => {
const { out } = await setup() const { out } = await setup()

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../llm/llm"
},
{
"path": "../user-interaction"
}
]
}

View File

@@ -32,7 +32,7 @@ Each tool is registered independently; a product that wants only one disables th
Tool registration follows product **enablement**, not backend availability. A tool stays visible even when its selected provider is missing, misconfigured, ambiguous, or temporarily unavailable; the seam resolves the provider at execution time and execution fails with a structured `WebError` (e.g. `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`), which `ToolRegistry.execute()` turns into an error tool result the model can read and hooks/UI can route on. This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. To remove a web tool entirely, disable it here in config. Tool registration follows product **enablement**, not backend availability. A tool stays visible even when its selected provider is missing, misconfigured, ambiguous, or temporarily unavailable; the seam resolves the provider at execution time and execution fails with a structured `WebError` (e.g. `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`), which `ToolRegistry.execute()` turns into an error tool result the model can read and hooks/UI can route on. This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. To remove a web tool entirely, disable it here in config.
The tool never calls a provider's `status()` and never enumerates providers — its only execution path is `ctx.web.search()` / `ctx.web.fetch()`, and provider unavailability reaches it as the structured `WebError` codes selection throws at execution time. Provider selection stays entirely inside the seam, with one owner. The tool never calls a provider's `available()` and never enumerates providers — its only execution path is `ctx.web.search()` / `ctx.web.fetch()`, and provider unavailability reaches it as the structured `WebError` codes selection throws at execution time. Provider selection stays entirely inside the seam, with one owner.
## Model Experience ## Model Experience

View File

@@ -96,7 +96,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number): void {
const input = parseFetchArgs(args) const input = parseFetchArgs(args)
const result = await ctx.web.fetch( const result = await ctx.web.fetch(
{ url: input.url }, { url: input.url },
exec.signal ? { signal: exec.signal } : undefined, exec.signal,
) )
return [{ type: 'text', text: formatFetchOutput(result) }] return [{ type: 'text', text: formatFetchOutput(result) }]
}, },

View File

@@ -113,7 +113,7 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs:
const input = parseSearchArgs(args) const input = parseSearchArgs(args)
const result = await ctx.web.search( const result = await ctx.web.search(
{ query: input.query, maxResults }, { query: input.query, maxResults },
exec.signal ? { signal: exec.signal } : undefined, exec.signal,
) )
return [{ type: 'text', text: formatSearchOutput(result) }] return [{ type: 'text', text: formatSearchOutput(result) }]
}, },

View File

@@ -134,7 +134,7 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc
await tctx.plugin(ToolRegistry) await tctx.plugin(ToolRegistry)
await tctx.plugin(WebService, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID }) await tctx.plugin(WebService, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
// Provider backstop well ABOVE the tool-call budget, so the policy wins. // Provider backstop well ABOVE the tool-call budget, so the policy wins.
await tctx.plugin(WebFetchLocal, { timeoutMs: 30_000, maxTimeoutMs: 60_000 }) await tctx.plugin(WebFetchLocal, { timeoutMs: 30_000 })
await tctx.plugin(TimeoutPolicy) await tctx.plugin(TimeoutPolicy)
// The tool-call budget is declared by tool-web config, enforced by the policy. // The tool-call budget is declared by tool-web config, enforced by the policy.
tfiber = await tctx.plugin(ToolWeb, { fetchTimeoutMs: 50 }) tfiber = await tctx.plugin(ToolWeb, { fetchTimeoutMs: 50 })
@@ -156,11 +156,18 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc
expect(text).toContain('timed out after 50ms') expect(text).toContain('timed out after 50ms')
}) })
it('the provider backstop still protects a DIRECT ctx.web.fetch() call (no tool-call policy in that path)', async () => { it('the provider backstop still protects a direct provider call (no tool-call policy in that path)', async () => {
// A direct seam caller does not go through tools/execute, so the tool-call policy never // A direct provider caller bypasses tools/execute, so a short configured backstop
// applies; the provider's own timeout is the only budget. A short request hint must therefore // must produce provider-owned WEB_FETCH_TIMEOUT rather than TOOL_TIMEOUT.
// produce provider-owned `WEB_FETCH_TIMEOUT`, never `TOOL_TIMEOUT`. const direct = new WebFetchLocal.LocalFetchProvider({
const err = await tctx.web.fetch({ url: slowBase, timeoutMs: 50 }).then( maxUrlLength: 2048,
maxResponseBytes: 5_000_000,
maxBodyChars: 100_000,
timeoutMs: 50,
maxRedirects: 5,
userAgent: 'integration-test',
})
const err = await direct.fetch({ url: slowBase }).then(
() => undefined, () => undefined,
(e: unknown) => e as { code?: string }, (e: unknown) => e as { code?: string },
) )

View File

@@ -4,7 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools' import ToolRegistry from '@deepseek-ai/dsh-tools'
import WebService from '@deepseek-ai/dsh-web' import WebService from '@deepseek-ai/dsh-web'
import type { WebSearchProvider, WebSearchResult, WebProviderStatus } from '@deepseek-ai/dsh-web' import type { WebSearchProvider, WebSearchResult } from '@deepseek-ai/dsh-web'
import * as ToolWeb from '@deepseek-ai/dsh-tool-web' import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
import { import {
formatSearchOutput, formatSearchOutput,
@@ -18,10 +18,10 @@ import {
WEB_SEARCH_MAX_RESULTS, WEB_SEARCH_MAX_RESULTS,
} from '@deepseek-ai/dsh-tool-web' } from '@deepseek-ai/dsh-tool-web'
const available: WebProviderStatus = { available: true } const available = true
function searchProvider(result: WebSearchResult, status: WebProviderStatus = available): WebSearchProvider { function searchProvider(result: WebSearchResult, isAvailable = available): WebSearchProvider {
return { id: 'stub-search', status: () => status, search: () => Promise.resolve(result) } return { id: 'stub-search', available: () => isAvailable, search: () => Promise.resolve(result) }
} }
/** Mount the real registry, seam, and tool-web; return an executor helper. */ /** Mount the real registry, seam, and tool-web; return an executor helper. */
@@ -46,7 +46,7 @@ async function mountTools(opts: {
describe('search formatting', () => { describe('search formatting', () => {
it('renders content, sources with titles/hostnames, snippets, and a citation reminder', () => { it('renders content, sources with titles/hostnames, snippets, and a citation reminder', () => {
const out = formatSearchOutput({ const out = formatSearchOutput({
providerId: 'p', query: 'q', content: 'an answer', truncated: false, content: 'an answer', truncated: false,
sources: [ sources: [
{ url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' }, { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' },
{ url: 'https://b.test/y' }, { url: 'https://b.test/y' },
@@ -59,19 +59,19 @@ describe('search formatting', () => {
}) })
it('reports no results when there is neither content nor sources', () => { it('reports no results when there is neither content nor sources', () => {
expect(formatSearchOutput({ providerId: 'p', query: 'q', sources: [], truncated: false })) expect(formatSearchOutput({ sources: [], truncated: false }))
.toContain('No results found.') .toContain('No results found.')
}) })
it('renders content alone when there are no sources', () => { it('renders content alone when there are no sources', () => {
const out = formatSearchOutput({ providerId: 'p', query: 'q', content: 'just an answer', sources: [], truncated: false }) const out = formatSearchOutput({ content: 'just an answer', sources: [], truncated: false })
expect(out).toContain('just an answer') expect(out).toContain('just an answer')
expect(out).not.toContain('No results found.') expect(out).not.toContain('No results found.')
expect(out).not.toContain('Sources:') expect(out).not.toContain('Sources:')
}) })
it('notes truncation', () => { it('notes truncation', () => {
const out = formatSearchOutput({ providerId: 'p', query: 'q', sources: [{ url: 'https://a.test' }], truncated: true }) const out = formatSearchOutput({ sources: [{ url: 'https://a.test' }], truncated: true })
expect(out).toContain('Showing the first 1 sources') expect(out).toContain('Showing the first 1 sources')
}) })
@@ -88,7 +88,7 @@ describe('search formatting', () => {
describe('fetch formatting', () => { describe('fetch formatting', () => {
it('renders an html body to markdown text with a status header', () => { it('renders an html body to markdown text with a status header', () => {
const out = formatFetchOutput({ const out = formatFetchOutput({
providerId: 'p', url: 'https://a.test', statusCode: 200, truncated: false, url: 'https://a.test', statusCode: 200, truncated: false,
body: { kind: 'html', content: '<h1>Title</h1><p>Body text</p>' }, body: { kind: 'html', content: '<h1>Title</h1><p>Body text</p>' },
}) })
expect(out).toContain('Fetched https://a.test (HTTP 200)') expect(out).toContain('Fetched https://a.test (HTTP 200)')
@@ -98,7 +98,7 @@ describe('fetch formatting', () => {
it('passes a text body through and notes truncation', () => { it('passes a text body through and notes truncation', () => {
const out = formatFetchOutput({ const out = formatFetchOutput({
providerId: 'p', url: 'https://a.test', statusCode: 200, truncated: true, url: 'https://a.test', statusCode: 200, truncated: true,
body: { kind: 'text', content: 'plain' }, body: { kind: 'text', content: 'plain' },
}) })
expect(out).toContain('plain') expect(out).toContain('plain')
@@ -155,7 +155,7 @@ describe('htmlToMarkdown', () => {
}) })
it('falls back to the raw URL as a source label when the URL is unparseable', () => { it('falls back to the raw URL as a source label when the URL is unparseable', () => {
const out = formatSearchOutput({ providerId: 'p', query: 'q', truncated: false, sources: [{ url: 'not a url' }] }) const out = formatSearchOutput({ truncated: false, sources: [{ url: 'not a url' }] })
expect(out).toContain('[not a url](not a url)') expect(out).toContain('[not a url](not a url)')
}) })
}) })
@@ -209,7 +209,7 @@ describe('tool-web registration', () => {
describe('tool-web execution through the real registry', () => { describe('tool-web execution through the real registry', () => {
it('executes web_search and formats the result', async () => { it('executes web_search and formats the result', async () => {
const result: WebSearchResult = { const result: WebSearchResult = {
providerId: 'stub-search', query: 'q', content: 'answer', truncated: false, content: 'answer', truncated: false,
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip' }], sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip' }],
} }
const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) }) const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) })
@@ -228,8 +228,8 @@ describe('tool-web execution through the real registry', () => {
}) })
it('surfaces WEB_PROVIDER_AMBIGUOUS for multiple unconfigured providers', async () => { it('surfaces WEB_PROVIDER_AMBIGUOUS for multiple unconfigured providers', async () => {
const { ctx, fiber, call } = await mountTools({ search: searchProvider({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }) const { ctx, fiber, call } = await mountTools({ search: searchProvider({ sources: [], truncated: false }) })
ctx.web.registerSearchProvider({ id: 'other', status: () => available, search: () => Promise.resolve({ providerId: 'other', query: 'q', sources: [], truncated: false }) }) ctx.web.registerSearchProvider({ id: 'other', available: () => available, search: () => Promise.resolve({ sources: [], truncated: false }) })
const out = await call('web_search', { query: 'q' }) const out = await call('web_search', { query: 'q' })
expect(out.isError).toBe(true) expect(out.isError).toBe(true)
expect(out.error?.code).toBe('WEB_PROVIDER_AMBIGUOUS') expect(out.error?.code).toBe('WEB_PROVIDER_AMBIGUOUS')
@@ -237,7 +237,7 @@ describe('tool-web execution through the real registry', () => {
}) })
it('rejects invalid arguments with a structured INVALID_ARGS error', async () => { it('rejects invalid arguments with a structured INVALID_ARGS error', async () => {
const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }) const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ sources: [], truncated: false }) })
const out = await call('web_search', { query: 123 }) const out = await call('web_search', { query: 123 })
expect(out.isError).toBe(true) expect(out.isError).toBe(true)
expect(out.error?.code).toBe('INVALID_ARGS') expect(out.error?.code).toBe('INVALID_ARGS')
@@ -249,14 +249,14 @@ describe('tool-web execution through the real registry', () => {
}) })
it('executes web_fetch, forwarding the url (no timeout param) and the abort signal to the seam', async () => { it('executes web_fetch, forwarding the url (no timeout param) and the abort signal to the seam', async () => {
const seen: { request?: { url: string; timeoutMs?: number }; signal?: AbortSignal | undefined } = {} const seen: { request?: { url: string }; signal?: AbortSignal | undefined } = {}
const fetchProvider = { const fetchProvider = {
id: 'stub-fetch', id: 'stub-fetch',
status: () => available, available: () => available,
fetch: (request: { url: string; timeoutMs?: number }, exec?: { signal?: AbortSignal }) => { fetch: (request: { url: string }, signal?: AbortSignal) => {
seen.request = request seen.request = request
seen.signal = exec?.signal seen.signal = signal
return Promise.resolve({ providerId: 'stub-fetch', url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false }) return Promise.resolve({ url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
}, },
} }
const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider }) const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
@@ -271,21 +271,21 @@ describe('tool-web execution through the real registry', () => {
}) })
it('executes web_fetch with no caller signal (forwards undefined to the seam)', async () => { it('executes web_fetch with no caller signal (forwards undefined to the seam)', async () => {
const seen: { signal?: AbortSignal | undefined; passedExec?: boolean } = {} const seen: { signal?: AbortSignal | undefined; passedSignal?: boolean } = {}
const fetchProvider = { const fetchProvider = {
id: 'stub-fetch', id: 'stub-fetch',
status: () => available, available: () => available,
fetch: (request: { url: string }, exec?: { signal?: AbortSignal }) => { fetch: (request: { url: string }, signal?: AbortSignal) => {
seen.passedExec = exec !== undefined seen.passedSignal = signal !== undefined
seen.signal = exec?.signal seen.signal = signal
return Promise.resolve({ providerId: 'stub-fetch', url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false }) return Promise.resolve({ url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
}, },
} }
const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider }) const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
// No signal on the execution: the tool passes `undefined` (not `{ signal: undefined }`). // No signal on the execution: the tool passes `undefined`.
const out = await ctx.tools.execute({ callId: CallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } }) const out = await ctx.tools.execute({ callId: CallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } })
expect(out.isError).toBe(false) expect(out.isError).toBe(false)
expect(seen.passedExec).toBe(false) expect(seen.passedSignal).toBe(false)
expect(seen.signal).toBeUndefined() expect(seen.signal).toBeUndefined()
await fiber.dispose() await fiber.dispose()
}) })
@@ -294,8 +294,8 @@ describe('tool-web execution through the real registry', () => {
const seen: { signal?: AbortSignal | undefined } = {} const seen: { signal?: AbortSignal | undefined } = {}
const provider: WebSearchProvider = { const provider: WebSearchProvider = {
id: 'stub-search', id: 'stub-search',
status: () => available, available: () => available,
search: (_request, exec) => { seen.signal = exec?.signal; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }, search: (_request, signal) => { seen.signal = signal; return Promise.resolve({ sources: [], truncated: false }) },
} }
const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
const controller = new AbortController() const controller = new AbortController()
@@ -310,8 +310,8 @@ describe('searchMaxResults is plugin config', () => {
const seen: { maxResults?: number | undefined } = {} const seen: { maxResults?: number | undefined } = {}
const provider: WebSearchProvider = { const provider: WebSearchProvider = {
id: 'stub-search', id: 'stub-search',
status: () => available, available: () => available,
search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }, search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ sources: [], truncated: false }) },
} }
const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
await call('web_search', { query: 'q' }) await call('web_search', { query: 'q' })
@@ -323,8 +323,8 @@ describe('searchMaxResults is plugin config', () => {
const sources = Array.from({ length: 5 }, (_, i) => ({ url: `https://s${i}.test` })) const sources = Array.from({ length: 5 }, (_, i) => ({ url: `https://s${i}.test` }))
const provider: WebSearchProvider = { const provider: WebSearchProvider = {
id: 'stub-search', id: 'stub-search',
status: () => available, available: () => available,
search: request => Promise.resolve({ providerId: 'stub-search', query: request.query, sources, truncated: false }), search: () => Promise.resolve({ sources, truncated: false }),
} }
const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider }) const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider })
const out = await call('web_search', { query: 'q' }) const out = await call('web_search', { query: 'q' })

View File

@@ -8,7 +8,7 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
The provider owns **safe resource retrieval**: URL validation, HTTP transport, redirect policy, a resource-backstop timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `@deepseek-ai/dsh-tool-web` owns **presentation** (HTML→markdown, truncation formatting). A non-2xx HTTP response is a *result* (status code + decoded body), not an error; `WebError` is reserved for failures to safely retrieve or represent the resource. The provider owns **safe resource retrieval**: URL validation, HTTP transport, redirect policy, a resource-backstop timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `@deepseek-ai/dsh-tool-web` owns **presentation** (HTML→markdown, truncation formatting). A non-2xx HTTP response is a *result* (status code + decoded body), not an error; `WebError` is reserved for failures to safely retrieve or represent the resource.
The provider's `timeoutMs`/`maxTimeoutMs` is a resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments, not the model-facing tool-call budget. [`dsh-timeout-policy`](../../timeout/timeout-policy/README.md) owns the `web_fetch` tool-call budget by arming `exec.signal`. The provider's `timeoutMs` is a resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments, not the model-facing tool-call budget. [`dsh-timeout-policy`](../../timeout/timeout-policy/README.md) owns the `web_fetch` tool-call budget by arming `exec.signal`.
A shipping web-tool deployment sets the provider backstop above the tool budget, so model calls normally return `TOOL_TIMEOUT`. If the outer deadline reaches the provider first, the provider reports `WEB_ABORTED` and the outer policy replaces it with `TOOL_TIMEOUT`. `WEB_FETCH_TIMEOUT` therefore identifies a direct seam caller whose provider budget elapsed. A shipping web-tool deployment sets the provider backstop above the tool budget, so model calls normally return `TOOL_TIMEOUT`. If the outer deadline reaches the provider first, the provider reports `WEB_ABORTED` and the outer policy replaces it with `TOOL_TIMEOUT`. `WEB_FETCH_TIMEOUT` therefore identifies a direct seam caller whose provider budget elapsed.
@@ -28,8 +28,7 @@ A shipping web-tool deployment sets the provider backstop above the tool budget,
| `maxUrlLength` | `2048` | Maximum accepted request URL length. | | `maxUrlLength` | `2048` | Maximum accepted request URL length. |
| `maxResponseBytes` | `5_000_000` | Maximum response body size in bytes. | | `maxResponseBytes` | `5_000_000` | Maximum response body size in bytes. |
| `maxBodyChars` | `100_000` | Maximum decoded body length in characters. | | `maxBodyChars` | `100_000` | Maximum decoded body length in characters. |
| `timeoutMs` | `30_000` | Default fetch timeout — a resource backstop for direct `ctx.web.fetch()` callers, not the model-facing tool-call budget (that is `dsh-timeout-policy`). | | `timeoutMs` | `30_000` | Fetch timeout within Node's timer range — a resource backstop for direct `ctx.web.fetch()` callers, not the model-facing tool-call budget (that is `dsh-timeout-policy`). |
| `maxTimeoutMs` | `120_000` | Upper bound for a per-request timeout override (direct callers). |
| `maxRedirects` | `5` | Maximum same-origin redirect hops (`0` follows none). | | `maxRedirects` | `5` | Maximum same-origin redirect hops (`0` follows none). |
| `userAgent` | `deepseek-harness/…` | `User-Agent` header. | | `userAgent` | `deepseek-harness/…` | `User-Agent` header. |

View File

@@ -13,6 +13,8 @@ import type {} from '@deepseek-ai/dsh-web'
import { LocalFetchProvider } from './provider.ts' import { LocalFetchProvider } from './provider.ts'
import type { LocalFetchLimits } from './provider.ts' import type { LocalFetchLimits } from './provider.ts'
const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647
export { export {
LOCAL_FETCH_PROVIDER_ID, LOCAL_FETCH_PROVIDER_ID,
LocalFetchProvider, LocalFetchProvider,
@@ -38,10 +40,8 @@ export interface Config {
maxResponseBytes?: number maxResponseBytes?: number
/** Maximum decoded body length in characters. */ /** Maximum decoded body length in characters. */
maxBodyChars?: number maxBodyChars?: number
/** Default fetch timeout in milliseconds. */ /** Default fetch timeout in milliseconds, within Node's timer range. */
timeoutMs?: number timeoutMs?: number
/** Upper bound for a per-request timeout override. */
maxTimeoutMs?: number
/** Maximum number of same-origin redirect hops to follow. */ /** Maximum number of same-origin redirect hops to follow. */
maxRedirects?: number maxRedirects?: number
/** `User-Agent` header sent on every request. */ /** `User-Agent` header sent on every request. */
@@ -53,7 +53,6 @@ export const Config: z<Config> = z.object({
maxResponseBytes: z.number().default(5_000_000), maxResponseBytes: z.number().default(5_000_000),
maxBodyChars: z.number().default(100_000), maxBodyChars: z.number().default(100_000),
timeoutMs: z.number().default(30_000), timeoutMs: z.number().default(30_000),
maxTimeoutMs: z.number().default(120_000),
maxRedirects: z.number().default(5), maxRedirects: z.number().default(5),
userAgent: z.string().default(DEFAULT_USER_AGENT), userAgent: z.string().default(DEFAULT_USER_AGENT),
}) })
@@ -68,6 +67,14 @@ function assertPositiveFinite(name: string, value: number): void {
} }
} }
/** Node coerces larger timer delays to 1 ms, so reject them at configuration time. */
function assertTimeoutMs(value: number): void {
assertPositiveFinite('timeoutMs', value)
if (value > MAX_NODE_TIMER_DELAY_MS) {
throw new Error(`web-fetch-local: timeoutMs must be no greater than ${MAX_NODE_TIMER_DELAY_MS}`)
}
}
/** The redirect hop cap must be a non-negative integer (0 follows no redirects). */ /** The redirect hop cap must be a non-negative integer (0 follows no redirects). */
function assertNonNegativeInteger(name: string, value: number): void { function assertNonNegativeInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 0) { if (!Number.isInteger(value) || value < 0) {
@@ -82,15 +89,13 @@ export function apply(ctx: Context, config: Config): void {
assertPositiveFinite('maxUrlLength', resolved.maxUrlLength) assertPositiveFinite('maxUrlLength', resolved.maxUrlLength)
assertPositiveFinite('maxResponseBytes', resolved.maxResponseBytes) assertPositiveFinite('maxResponseBytes', resolved.maxResponseBytes)
assertPositiveFinite('maxBodyChars', resolved.maxBodyChars) assertPositiveFinite('maxBodyChars', resolved.maxBodyChars)
assertPositiveFinite('timeoutMs', resolved.timeoutMs) assertTimeoutMs(resolved.timeoutMs)
assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
assertNonNegativeInteger('maxRedirects', resolved.maxRedirects) assertNonNegativeInteger('maxRedirects', resolved.maxRedirects)
const limits: LocalFetchLimits = { const limits: LocalFetchLimits = {
maxUrlLength: resolved.maxUrlLength, maxUrlLength: resolved.maxUrlLength,
maxResponseBytes: resolved.maxResponseBytes, maxResponseBytes: resolved.maxResponseBytes,
maxBodyChars: resolved.maxBodyChars, maxBodyChars: resolved.maxBodyChars,
timeoutMs: resolved.timeoutMs, timeoutMs: resolved.timeoutMs,
maxTimeoutMs: resolved.maxTimeoutMs,
maxRedirects: resolved.maxRedirects, maxRedirects: resolved.maxRedirects,
userAgent: resolved.userAgent, userAgent: resolved.userAgent,
} }

View File

@@ -9,8 +9,8 @@
*/ */
import { WebError } from '@deepseek-ai/dsh-web' import { WebError } from '@deepseek-ai/dsh-web'
import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult, WebProviderStatus } from '@deepseek-ai/dsh-web' import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult } from '@deepseek-ai/dsh-web'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts' import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts'
/** Resolved provider limits (the plugin's schemastery Config supplies defaults). */ /** Resolved provider limits (the plugin's schemastery Config supplies defaults). */
@@ -23,8 +23,6 @@ export interface LocalFetchLimits {
maxBodyChars: number maxBodyChars: number
/** Default fetch timeout in milliseconds. */ /** Default fetch timeout in milliseconds. */
timeoutMs: number timeoutMs: number
/** Upper bound for a per-request timeout override. */
maxTimeoutMs: number
/** Maximum number of (same-origin) redirect hops to follow. */ /** Maximum number of (same-origin) redirect hops to follow. */
maxRedirects: number maxRedirects: number
/** `User-Agent` header sent on every request. */ /** `User-Agent` header sent on every request. */
@@ -41,17 +39,16 @@ export class LocalFetchProvider implements WebFetchProvider {
constructor(private readonly limits: LocalFetchLimits) {} constructor(private readonly limits: LocalFetchLimits) {}
/** No credentials to check — an anonymous public fetcher is always usable. */ /** No credentials to check — an anonymous public fetcher is always usable. */
status(): WebProviderStatus { available(): boolean {
return { available: true } return true
} }
async fetch(request: WebFetchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebFetchResult> { async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> {
if (exec?.signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED') if (signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED')
const timeoutMs = clampTimeout(request.timeoutMs, this.limits.timeoutMs, this.limits.maxTimeoutMs)
// One signal stops both the request and body read. The deadline's TimeoutReason later // One signal stops both the request and body read. The deadline's TimeoutReason later
// distinguishes this provider's timeout from caller or outer-deadline cancellation. // distinguishes this provider's timeout from caller or outer-deadline cancellation.
using d = deadline(exec?.signal, timeoutMs, 'WEB_FETCH_TIMEOUT') using d = deadline(signal, this.limits.timeoutMs, 'WEB_FETCH_TIMEOUT')
return await this.followAndRead(request.url, d.signal) return await this.followAndRead(request.url, d.signal)
} }
@@ -142,7 +139,6 @@ export class LocalFetchProvider implements WebFetchProvider {
const body: WebFetchBody = kind === 'html' ? { kind: 'html', content } : { kind: 'text', content } const body: WebFetchBody = kind === 'html' ? { kind: 'html', content } : { kind: 'text', content }
return { return {
providerId: this.id,
url: finalUrl.toString(), url: finalUrl.toString(),
statusCode: response.status, statusCode: response.status,
body, body,

View File

@@ -12,7 +12,6 @@ const limits: LocalFetchLimits = {
maxResponseBytes: 5_000_000, maxResponseBytes: 5_000_000,
maxBodyChars: 100_000, maxBodyChars: 100_000,
timeoutMs: 5_000, timeoutMs: 5_000,
maxTimeoutMs: 10_000,
maxRedirects: 5, maxRedirects: 5,
userAgent: 'test-agent/1.0', userAgent: 'test-agent/1.0',
} }
@@ -82,7 +81,7 @@ describe('LocalFetchProvider success', () => {
it('fetches a text body', async () => { it('fetches a text body', async () => {
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello world') } handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello world') }
const result = await provider().fetch({ url: base }) const result = await provider().fetch({ url: base })
expect(result.providerId).toBe(LOCAL_FETCH_PROVIDER_ID) expect(provider().available()).toBe(true)
expect(result.statusCode).toBe(200) expect(result.statusCode).toBe(200)
expect(result.body).toEqual({ kind: 'text', content: 'hello world' }) expect(result.body).toEqual({ kind: 'text', content: 'hello world' })
expect(result.truncated).toBe(false) expect(result.truncated).toBe(false)
@@ -287,14 +286,14 @@ describe('LocalFetchProvider invalid URLs and abort', () => {
it('honors a pre-aborted signal', async () => { it('honors a pre-aborted signal', async () => {
const controller = new AbortController() const controller = new AbortController()
controller.abort() controller.abort()
await expect(provider().fetch({ url: base }, { signal: controller.signal })) await expect(provider().fetch({ url: base }, controller.signal))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
}) })
it('aborts an in-flight fetch via the signal', async () => { it('aborts an in-flight fetch via the signal', async () => {
handler = (_req, _res) => { /* never responds */ } handler = (_req, _res) => { /* never responds */ }
const controller = new AbortController() const controller = new AbortController()
const promise = provider().fetch({ url: base }, { signal: controller.signal }) const promise = provider().fetch({ url: base }, controller.signal)
controller.abort() controller.abort()
await expect(promise).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) await expect(promise).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
}) })
@@ -325,11 +324,6 @@ describe('LocalFetchProvider invalid URLs and abort', () => {
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
}) })
it('caps the per-request timeout at maxTimeoutMs', async () => {
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok') }
const result = await provider({ maxTimeoutMs: 10_000 }).fetch({ url: base, timeoutMs: 999_999 })
expect(result.statusCode).toBe(200)
})
}) })
describe('LocalFetchProvider body cancellation on error paths', () => { describe('LocalFetchProvider body cancellation on error paths', () => {
@@ -378,7 +372,7 @@ describe('web-fetch-local plugin registration', () => {
await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
const fiber = await ctx.plugin(fetchPlugin, {}) const fiber = await ctx.plugin(fetchPlugin, {})
await expect(ctx.web.fetch({ url: `${base}/` })) await expect(ctx.web.fetch({ url: `${base}/` }))
.resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 }) .resolves.toMatchObject({ statusCode: 200 })
await fiber.dispose() await fiber.dispose()
await expect(ctx.web.fetch({ url: `${base}/` })) await expect(ctx.web.fetch({ url: `${base}/` }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
@@ -402,6 +396,13 @@ describe('web-fetch-local plugin registration', () => {
.rejects.toThrow(/timeoutMs must be a positive finite number/) .rejects.toThrow(/timeoutMs must be a positive finite number/)
}) })
it('rejects a timeout beyond Node timer range at construction', async () => {
const ctx = new Context()
await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
await expect(ctx.plugin(fetchPlugin, { timeoutMs: 2_147_483_648 }))
.rejects.toThrow(/timeoutMs must be no greater than 2147483647/)
})
it('rejects a fractional redirect cap at construction', async () => { it('rejects a fractional redirect cap at construction', async () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
@@ -421,7 +422,7 @@ describe('web-fetch-local plugin registration', () => {
await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 }) const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 })
await expect(ctx.web.fetch({ url: `${base}/` })) await expect(ctx.web.fetch({ url: `${base}/` }))
.resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 }) .resolves.toMatchObject({ statusCode: 200 })
await fiber.dispose() await fiber.dispose()
}) })
}) })

View File

@@ -16,8 +16,8 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`:
| Key | Default | Meaning | | Key | Default | Meaning |
|---|---|---| |---|---|---|
| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent → provider `status()` reports `missing-credential`. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). | | `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent makes the provider unavailable. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). |
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes `status()` report `misconfigured`. | | `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. |
| `model` | `deepseek-v4-flash` | Anthropic-format model name. | | `model` | `deepseek-v4-flash` | Anthropic-format model name. |
| `apiVersion` | `2023-06-01` | `anthropic-version` header value. | | `apiVersion` | `2023-06-01` | `anthropic-version` header value. |
| `maxTokens` | `4096` | Positive-integer upper bound on generated tokens for the Messages request. | | `maxTokens` | `4096` | Positive-integer upper bound on generated tokens for the Messages request. |

View File

@@ -8,7 +8,6 @@
import { WebError } from '@deepseek-ai/dsh-web' import { WebError } from '@deepseek-ai/dsh-web'
import type { import type {
WebProviderStatus,
WebSearchProvider, WebSearchProvider,
WebSearchRequest, WebSearchRequest,
WebSearchResult, WebSearchResult,
@@ -50,7 +49,7 @@ const USER_AGENT = 'deepseek-harness/0.0.1'
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
export interface DeepSeekSearchProviderOptions { export interface DeepSeekSearchProviderOptions {
/** DeepSeek API key. Empty/absent → `status()` reports `missing-credential`. */ /** DeepSeek API key. Empty/absent makes the provider unavailable. */
apiKey: string apiKey: string
/** Endpoint base; `/messages` is appended. */ /** Endpoint base; `/messages` is appended. */
baseURL: string baseURL: string
@@ -93,12 +92,11 @@ export function citationSnippets(blocks: readonly ContentBlock[]): Map<string, s
* the same URL across searches). The seam owns the final `maxResults` truncation, so * the same URL across searches). The seam owns the final `maxResults` truncation, so
* `truncated` is always `false` here. * `truncated` is always `false` here.
* *
* @param query - the original request query, echoed on the result.
* @param response - the parsed Messages response body. * @param response - the parsed Messages response body.
* @returns the normalized result with deduped, snippet-joined sources. * @returns the normalized result with deduped, snippet-joined sources.
* @throws {@link WebError} when native search produced no result block. * @throws {@link WebError} when native search produced no result block.
*/ */
export function mapAnthropicResponse(query: string, response: AnthropicResponse): WebSearchResult { export function mapAnthropicResponse(response: AnthropicResponse): WebSearchResult {
const blocks = response.content ?? [] const blocks = response.content ?? []
const resultBlocks = blocks.filter( const resultBlocks = blocks.filter(
(block): block is WebSearchToolResultBlock => block.type === 'web_search_tool_result', (block): block is WebSearchToolResultBlock => block.type === 'web_search_tool_result',
@@ -126,7 +124,7 @@ export function mapAnthropicResponse(query: string, response: AnthropicResponse)
}) })
} }
} }
return { providerId: DEEPSEEK_PROVIDER_ID, query, sources, truncated: false } return { sources, truncated: false }
} }
/** The DeepSeek-backed search provider. */ /** The DeepSeek-backed search provider. */
@@ -135,14 +133,14 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
constructor(private readonly options: DeepSeekSearchProviderOptions) {} constructor(private readonly options: DeepSeekSearchProviderOptions) {}
status(): WebProviderStatus { available(): boolean {
if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } return this.options.apiKey.length > 0
if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' } && URL.canParse(this.options.baseURL)
if (!isPositiveInteger(this.options.maxTokens) || !isPositiveInteger(this.options.maxUses)) return { available: false, reason: 'misconfigured' } && isPositiveInteger(this.options.maxTokens)
return { available: true } && isPositiveInteger(this.options.maxUses)
} }
async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> { async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
let response: Response let response: Response
try { try {
response = await fetch(`${this.options.baseURL}/messages`, { response = await fetch(`${this.options.baseURL}/messages`, {
@@ -166,7 +164,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
}], }],
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }], tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }],
}), }),
...exec?.signal ? { signal: exec.signal } : {}, ...signal !== undefined ? { signal } : {},
}) })
} catch (error: unknown) { } catch (error: unknown) {
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
@@ -194,7 +192,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
try { try {
const payload = await response.json() as AnthropicResponse const payload = await response.json() as AnthropicResponse
return mapAnthropicResponse(request.query, payload) return mapAnthropicResponse(payload)
} catch (error: unknown) { } catch (error: unknown) {
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
if (error instanceof WebError) throw error if (error instanceof WebError) throw error

View File

@@ -29,7 +29,6 @@ maybe('DeepSeekSearchProvider real API', () => {
maxUses: DEEPSEEK_DEFAULT_MAX_USES, maxUses: DEEPSEEK_DEFAULT_MAX_USES,
}) })
const result = await provider.search({ query: 'What is the DeepSeek Harness SDK?', maxResults: 5 }) const result = await provider.search({ query: 'What is the DeepSeek Harness SDK?', maxResults: 5 })
expect(result.providerId).toBe('deepseek')
expect(result.sources.length).toBeGreaterThan(0) expect(result.sources.length).toBeGreaterThan(0)
for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//)
}, 60_000) }, 60_000)

View File

@@ -64,10 +64,8 @@ describe('citationSnippets', () => {
describe('mapAnthropicResponse', () => { describe('mapAnthropicResponse', () => {
it('joins result items to citation snippets and maps page_age to publishedAt', () => { it('joins result items to citation snippets and maps page_age to publishedAt', () => {
const result = mapAnthropicResponse('q', searchResponse()) const result = mapAnthropicResponse(searchResponse())
expect(result).toEqual({ expect(result).toEqual({
providerId: DEEPSEEK_PROVIDER_ID,
query: 'q',
sources: [ sources: [
{ url: 'https://a.test', title: 'A', snippet: 'excerpt for A', publishedAt: '2026-02-02' }, { url: 'https://a.test', title: 'A', snippet: 'excerpt for A', publishedAt: '2026-02-02' },
{ url: 'https://b.test', title: 'B' }, { url: 'https://b.test', title: 'B' },
@@ -77,7 +75,7 @@ describe('mapAnthropicResponse', () => {
}) })
it('dedupes repeated urls across result blocks (first wins)', () => { it('dedupes repeated urls across result blocks (first wins)', () => {
const result = mapAnthropicResponse('q', { const result = mapAnthropicResponse({
content: [ content: [
{ type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'first' }] }, { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'first' }] },
{ type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'second' }] }, { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'second' }] },
@@ -87,7 +85,7 @@ describe('mapAnthropicResponse', () => {
}) })
it('skips non-result items and items with an empty url', () => { it('skips non-result items and items with an empty url', () => {
const result = mapAnthropicResponse('q', { const result = mapAnthropicResponse({
content: [{ content: [{
type: 'web_search_tool_result', type: 'web_search_tool_result',
content: [ content: [
@@ -101,14 +99,14 @@ describe('mapAnthropicResponse', () => {
}) })
it('omits optional fields when absent or empty', () => { it('omits optional fields when absent or empty', () => {
const result = mapAnthropicResponse('q', { const result = mapAnthropicResponse({
content: [{ type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: '', page_age: '' }] }], content: [{ type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: '', page_age: '' }] }],
}) })
expect(result.sources).toEqual([{ url: 'https://a.test' }]) expect(result.sources).toEqual([{ url: 'https://a.test' }])
}) })
it('tolerates a text block with no citations', () => { it('tolerates a text block with no citations', () => {
const result = mapAnthropicResponse('q', { const result = mapAnthropicResponse({
content: [ content: [
{ type: 'text', text: 'no citations here' }, { type: 'text', text: 'no citations here' },
{ type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'A' }] }, { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'A' }] },
@@ -118,7 +116,7 @@ describe('mapAnthropicResponse', () => {
}) })
it('tolerates a result block with no content array', () => { it('tolerates a result block with no content array', () => {
const result = mapAnthropicResponse('q', { const result = mapAnthropicResponse({
content: [ content: [
{ type: 'web_search_tool_result' }, { type: 'web_search_tool_result' },
{ type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test' }] }, { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test' }] },
@@ -128,38 +126,33 @@ describe('mapAnthropicResponse', () => {
}) })
it('throws WEB_PROVIDER_ERROR (strict mode) when no result block is present', () => { it('throws WEB_PROVIDER_ERROR (strict mode) when no result block is present', () => {
expect(() => mapAnthropicResponse('q', { content: [{ type: 'text', text: 'just prose, no search' }] })) expect(() => mapAnthropicResponse({ content: [{ type: 'text', text: 'just prose, no search' }] }))
.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) .toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
}) })
it('throws WEB_PROVIDER_ERROR when content is absent entirely', () => { it('throws WEB_PROVIDER_ERROR when content is absent entirely', () => {
expect(() => mapAnthropicResponse('q', {})) expect(() => mapAnthropicResponse({}))
.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) .toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
}) })
}) })
describe('DeepSeekSearchProvider status', () => { describe('DeepSeekSearchProvider availability', () => {
it('is unavailable without a key', () => { it('is unavailable without a key', () => {
expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).status()) expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).available()).toBe(false)
.toEqual({ available: false, reason: 'missing-credential' })
}) })
it('is available with a key', () => { it('is available with a key', () => {
expect(new DeepSeekSearchProvider(options).status()).toEqual({ available: true }) expect(new DeepSeekSearchProvider(options).available()).toBe(true)
}) })
it('is misconfigured when the base URL is unparseable', () => { it('is misconfigured when the base URL is unparseable', () => {
expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).status()) expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false)
.toEqual({ available: false, reason: 'misconfigured' })
}) })
it('is misconfigured when request limits are not positive integers', () => { it('is misconfigured when request limits are not positive integers', () => {
expect(new DeepSeekSearchProvider({ ...options, maxTokens: 0 }).status()) expect(new DeepSeekSearchProvider({ ...options, maxTokens: 0 }).available()).toBe(false)
.toEqual({ available: false, reason: 'misconfigured' }) expect(new DeepSeekSearchProvider({ ...options, maxUses: 0 }).available()).toBe(false)
expect(new DeepSeekSearchProvider({ ...options, maxUses: 0 }).status()) expect(new DeepSeekSearchProvider({ ...options, maxUses: 1.5 }).available()).toBe(false)
.toEqual({ available: false, reason: 'misconfigured' })
expect(new DeepSeekSearchProvider({ ...options, maxUses: 1.5 }).status())
.toEqual({ available: false, reason: 'misconfigured' })
}) })
}) })
@@ -186,7 +179,7 @@ describe('DeepSeekSearchProvider request mapping', () => {
const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController() const controller = new AbortController()
await new DeepSeekSearchProvider(options).search({ query: 'q' }, { signal: controller.signal }) await new DeepSeekSearchProvider(options).search({ query: 'q' }, controller.signal)
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(init.signal).toBe(controller.signal) expect(init.signal).toBe(controller.signal)
}) })
@@ -268,7 +261,7 @@ describe('web-search-deepseek plugin registration', () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
const fiber = await ctx.plugin(deepseekPlugin, { apiKey: 'ds-key' }) const fiber = await ctx.plugin(deepseekPlugin, { apiKey: 'ds-key' })
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID }) await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ truncated: false })
await fiber.dispose() await fiber.dispose()
await expect(ctx.web.search({ query: 'q' })) await expect(ctx.web.search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
@@ -318,7 +311,7 @@ describe('web-search-deepseek plugin registration', () => {
const unwrapped = loader.unwrapExports(deepseekPlugin) as Parameters<Context['plugin']>[0] const unwrapped = loader.unwrapExports(deepseekPlugin) as Parameters<Context['plugin']>[0]
// A collapsed export shape (dropped inject) would throw "without inject" here. // A collapsed export shape (dropped inject) would throw "without inject" here.
const fiber = await ctx.plugin(unwrapped, { apiKey: 'ds-key' }) const fiber = await ctx.plugin(unwrapped, { apiKey: 'ds-key' })
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID }) await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ truncated: false })
await fiber.dispose() await fiber.dispose()
}) })

View File

@@ -8,8 +8,8 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
| Key | Default | Meaning | | Key | Default | Meaning |
|---|---|---| |---|---|---|
| `apiKey` | `$EXA_API_KEY` | Exa API key. Empty/absent → provider `status()` reports `missing-credential` (the seam reports `configured-unavailable`/`none`). | | `apiKey` | `$EXA_API_KEY` | Exa API key. Empty/absent makes the provider unavailable. |
| `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. An unparseable value makes `status()` report `misconfigured`. | | `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. An unparseable value makes the provider unavailable. |
| `searchType` | `auto` | Retrieval mode sent as Exa's `type`: `auto` (Exa decides), `keyword`, or `neural`. | | `searchType` | `auto` | Retrieval mode sent as Exa's `type`: `auto` (Exa decides), `keyword`, or `neural`. |
| `numResults` | (unset) | Default result count when a request carries no `maxResults`. Unset sends no default. Must be a positive integer. | | `numResults` | (unset) | Default result count when a request carries no `maxResults`. Unset sends no default. Must be a positive integer. |
| `highlightsPerResult` | `1` | Highlight sentences requested per result (Exa's `highlightsPerUrl`). Must be a positive integer. | | `highlightsPerResult` | `1` | Highlight sentences requested per result (Exa's `highlightsPerUrl`). Must be a positive integer. |

View File

@@ -8,7 +8,6 @@
import { WebError } from '@deepseek-ai/dsh-web' import { WebError } from '@deepseek-ai/dsh-web'
import type { import type {
WebProviderStatus,
WebSearchProvider, WebSearchProvider,
WebSearchRequest, WebSearchRequest,
WebSearchResult, WebSearchResult,
@@ -33,7 +32,7 @@ const USER_AGENT = 'deepseek-harness/0.0.1'
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
export interface ExaSearchProviderOptions { export interface ExaSearchProviderOptions {
/** Exa API key. Empty/absent → `status()` reports `missing-credential`. */ /** Exa API key. Empty/absent makes the provider unavailable. */
apiKey: string apiKey: string
/** Endpoint base; `/search` is appended. */ /** Endpoint base; `/search` is appended. */
baseURL: string baseURL: string
@@ -68,18 +67,17 @@ export function mapExaResult(result: ExaResult): WebSearchSource | undefined {
/** /**
* Map an Exa response envelope to a normalized search result. * Map an Exa response envelope to a normalized search result.
* *
* @param query - the original request query, echoed on the result.
* @param response - the parsed `POST /search` response body. * @param response - the parsed `POST /search` response body.
* @returns the normalized result; snippet-less entries are dropped * @returns the normalized result; snippet-less entries are dropped
* ({@link mapExaResult}). * ({@link mapExaResult}).
*/ */
export function mapExaResponse(query: string, response: ExaSearchResponse): WebSearchResult { export function mapExaResponse(response: ExaSearchResponse): WebSearchResult {
const sources = (response.results ?? []) const sources = (response.results ?? [])
.map(mapExaResult) .map(mapExaResult)
.filter((source): source is WebSearchSource => source !== undefined) .filter((source): source is WebSearchSource => source !== undefined)
// Exa returns no generated answer, so `content` is omitted. The seam owns the // Exa returns no generated answer, so `content` is omitted. The seam owns the
// final `maxResults` truncation, so this provider reports `truncated: false`. // final `maxResults` truncation, so this provider reports `truncated: false`.
return { providerId: EXA_PROVIDER_ID, query, sources, truncated: false } return { sources, truncated: false }
} }
/** The Exa-backed search provider. */ /** The Exa-backed search provider. */
@@ -88,15 +86,14 @@ export class ExaSearchProvider implements WebSearchProvider {
constructor(private readonly options: ExaSearchProviderOptions) {} constructor(private readonly options: ExaSearchProviderOptions) {}
status(): WebProviderStatus { available(): boolean {
if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } return this.options.apiKey.length > 0
if (!isValidBaseUrl(this.options.baseURL)) return { available: false, reason: 'misconfigured' } && isValidBaseUrl(this.options.baseURL)
if (!isPositiveInteger(this.options.highlightsPerResult)) return { available: false, reason: 'misconfigured' } && isPositiveInteger(this.options.highlightsPerResult)
if (this.options.numResults !== undefined && !isPositiveInteger(this.options.numResults)) return { available: false, reason: 'misconfigured' } && (this.options.numResults === undefined || isPositiveInteger(this.options.numResults))
return { available: true }
} }
async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> { async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
// A per-request bound wins over the configured default; either may be absent. // A per-request bound wins over the configured default; either may be absent.
const numResults = request.maxResults ?? this.options.numResults const numResults = request.maxResults ?? this.options.numResults
let response: Response let response: Response
@@ -115,7 +112,7 @@ export class ExaSearchProvider implements WebSearchProvider {
contents: { highlights: { highlightsPerUrl: this.options.highlightsPerResult } }, contents: { highlights: { highlightsPerUrl: this.options.highlightsPerResult } },
...numResults !== undefined ? { numResults } : {}, ...numResults !== undefined ? { numResults } : {},
}), }),
...exec?.signal ? { signal: exec.signal } : {}, ...signal !== undefined ? { signal } : {},
}) })
} catch (error: unknown) { } catch (error: unknown) {
if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error })
@@ -143,7 +140,7 @@ export class ExaSearchProvider implements WebSearchProvider {
try { try {
const payload = await response.json() as ExaSearchResponse const payload = await response.json() as ExaSearchResponse
return mapExaResponse(request.query, payload) return mapExaResponse(payload)
} catch (error: unknown) { } catch (error: unknown) {
if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error })
throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })

View File

@@ -17,7 +17,6 @@ maybe('ExaSearchProvider real API', () => {
highlightsPerResult: EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, highlightsPerResult: EXA_DEFAULT_HIGHLIGHTS_PER_RESULT,
}) })
const result = await provider.search({ query: 'DeepSeek Harness SDK', maxResults: 5 }) const result = await provider.search({ query: 'DeepSeek Harness SDK', maxResults: 5 })
expect(result.providerId).toBe('exa')
expect(result.sources.length).toBeGreaterThan(0) expect(result.sources.length).toBeGreaterThan(0)
for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//)
}, 30_000) }, 30_000)

View File

@@ -38,7 +38,7 @@ describe('Exa result mapping', () => {
}) })
it('maps a response to a result with no content and filtered sources', () => { it('maps a response to a result with no content and filtered sources', () => {
const result = mapExaResponse('q', { const result = mapExaResponse({
results: [ results: [
{ url: 'https://a.test', highlights: ['one'] }, { url: 'https://a.test', highlights: ['one'] },
{ url: 'https://b.test' }, { url: 'https://b.test' },
@@ -46,8 +46,6 @@ describe('Exa result mapping', () => {
], ],
}) })
expect(result).toEqual({ expect(result).toEqual({
providerId: EXA_PROVIDER_ID,
query: 'q',
sources: [ sources: [
{ url: 'https://a.test', snippet: 'one' }, { url: 'https://a.test', snippet: 'one' },
{ url: 'https://c.test', title: 'C', snippet: 'three' }, { url: 'https://c.test', title: 'C', snippet: 'three' },
@@ -58,36 +56,31 @@ describe('Exa result mapping', () => {
}) })
it('tolerates a missing results array', () => { it('tolerates a missing results array', () => {
expect(mapExaResponse('q', {}).sources).toEqual([]) expect(mapExaResponse({}).sources).toEqual([])
}) })
}) })
describe('ExaSearchProvider status', () => { describe('ExaSearchProvider availability', () => {
it('is unavailable without a key', () => { it('is unavailable without a key', () => {
expect(new ExaSearchProvider({ ...options, apiKey: '' }).status()) expect(new ExaSearchProvider({ ...options, apiKey: '' }).available()).toBe(false)
.toEqual({ available: false, reason: 'missing-credential' })
}) })
it('is available with a key', () => { it('is available with a key', () => {
expect(new ExaSearchProvider(options).status()).toEqual({ available: true }) expect(new ExaSearchProvider(options).available()).toBe(true)
}) })
it('is misconfigured when the base URL is unparseable', () => { it('is misconfigured when the base URL is unparseable', () => {
expect(new ExaSearchProvider({ ...options, baseURL: 'not a url' }).status()) expect(new ExaSearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false)
.toEqual({ available: false, reason: 'misconfigured' })
}) })
it('is misconfigured when highlightsPerResult is not a positive integer', () => { it('is misconfigured when highlightsPerResult is not a positive integer', () => {
expect(new ExaSearchProvider({ ...options, highlightsPerResult: 0 }).status()) expect(new ExaSearchProvider({ ...options, highlightsPerResult: 0 }).available()).toBe(false)
.toEqual({ available: false, reason: 'misconfigured' }) expect(new ExaSearchProvider({ ...options, highlightsPerResult: 1.5 }).available()).toBe(false)
expect(new ExaSearchProvider({ ...options, highlightsPerResult: 1.5 }).status())
.toEqual({ available: false, reason: 'misconfigured' })
}) })
it('is misconfigured when numResults is set but not a positive integer', () => { it('is misconfigured when numResults is set but not a positive integer', () => {
expect(new ExaSearchProvider({ ...options, numResults: -1 }).status()) expect(new ExaSearchProvider({ ...options, numResults: -1 }).available()).toBe(false)
.toEqual({ available: false, reason: 'misconfigured' })
}) })
}) })
@@ -139,7 +132,7 @@ describe('ExaSearchProvider request mapping', () => {
const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController() const controller = new AbortController()
await new ExaSearchProvider(options).search({ query: 'q' }, { signal: controller.signal }) await new ExaSearchProvider(options).search({ query: 'q' }, controller.signal)
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(init.signal).toBe(controller.signal) expect(init.signal).toBe(controller.signal)
}) })
@@ -209,7 +202,7 @@ describe('web-search-exa plugin registration', () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID }) await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key' }) const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key' })
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: EXA_PROVIDER_ID }) await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ sources: [], truncated: false })
await fiber.dispose() await fiber.dispose()
await expect(ctx.web.search({ query: 'q' })) await expect(ctx.web.search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))

View File

@@ -8,8 +8,8 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
| Key | Default | Meaning | | Key | Default | Meaning |
|---|---|---| |---|---|---|
| `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key. Empty/absent → provider `status()` reports `missing-credential`. | | `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key. Empty/absent makes the provider unavailable. |
| `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. An unparseable value makes `status()` report `misconfigured`. | | `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. An unparseable value makes the provider unavailable. |
| `model` | `sonar` | Search model name. | | `model` | `sonar` | Search model name. |
| `maxTokens` | `1024` | Upper bound on generated answer tokens (`max_tokens`). Must be a positive integer. | | `maxTokens` | `1024` | Upper bound on generated answer tokens (`max_tokens`). Must be a positive integer. |
| `searchRecency` | (unset) | Recency window sent as `search_recency_filter`: `day`, `week`, `month`, or `year`. Unset sends no filter. | | `searchRecency` | (unset) | Recency window sent as `search_recency_filter`: `day`, `week`, `month`, or `year`. Unset sends no filter. |

View File

@@ -8,7 +8,6 @@
import { WebError } from '@deepseek-ai/dsh-web' import { WebError } from '@deepseek-ai/dsh-web'
import type { import type {
WebProviderStatus,
WebSearchProvider, WebSearchProvider,
WebSearchRequest, WebSearchRequest,
WebSearchResult, WebSearchResult,
@@ -36,7 +35,7 @@ const USER_AGENT = 'deepseek-harness/0.0.1'
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
export interface PerplexitySearchProviderOptions { export interface PerplexitySearchProviderOptions {
/** Perplexity API key. Empty/absent → `status()` reports `missing-credential`. */ /** Perplexity API key. Empty/absent makes the provider unavailable. */
apiKey: string apiKey: string
/** Endpoint base; `/chat/completions` is appended. */ /** Endpoint base; `/chat/completions` is appended. */
baseURL: string baseURL: string
@@ -68,18 +67,15 @@ export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSo
* structured `search_results[]`; falls back to URL-only `citations[]` (those * structured `search_results[]`; falls back to URL-only `citations[]` (those
* sources carry just a `url`) only when `search_results` is absent. * sources carry just a `url`) only when `search_results` is absent.
* *
* @param query - the original request query, echoed on the result.
* @param response - the parsed chat-completions response body. * @param response - the parsed chat-completions response body.
* @returns the normalized result; `content` is omitted when the answer is empty. * @returns the normalized result; `content` is omitted when the answer is empty.
*/ */
export function mapPerplexityResponse(query: string, response: PerplexityResponse): WebSearchResult { export function mapPerplexityResponse(response: PerplexityResponse): WebSearchResult {
const content = response.choices?.[0]?.message?.content const content = response.choices?.[0]?.message?.content
const sources: WebSearchSource[] = response.search_results !== undefined const sources: WebSearchSource[] = response.search_results !== undefined
? response.search_results.map(mapPerplexityResult) ? response.search_results.map(mapPerplexityResult)
: (response.citations ?? []).map(url => ({ url })) : (response.citations ?? []).map(url => ({ url }))
return { return {
providerId: PERPLEXITY_PROVIDER_ID,
query,
...content != null && content.length > 0 ? { content } : {}, ...content != null && content.length > 0 ? { content } : {},
sources, sources,
truncated: false, truncated: false,
@@ -95,15 +91,14 @@ export class PerplexitySearchProvider implements WebSearchProvider {
// Availability checks stay beside each provider's distinct config contract; // Availability checks stay beside each provider's distinct config contract;
// a shared base class would obscure which fields make this backend usable. // a shared base class would obscure which fields make this backend usable.
/* jscpd:ignore-start */ /* jscpd:ignore-start */
status(): WebProviderStatus { available(): boolean {
if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } return this.options.apiKey.length > 0
if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' } && URL.canParse(this.options.baseURL)
if (!isPositiveInteger(this.options.maxTokens)) return { available: false, reason: 'misconfigured' } && isPositiveInteger(this.options.maxTokens)
return { available: true }
} }
/* jscpd:ignore-end */ /* jscpd:ignore-end */
async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> { async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
let response: Response let response: Response
try { try {
response = await fetch(`${this.options.baseURL}/chat/completions`, { response = await fetch(`${this.options.baseURL}/chat/completions`, {
@@ -120,7 +115,7 @@ export class PerplexitySearchProvider implements WebSearchProvider {
messages: [{ role: 'user', content: request.query }], messages: [{ role: 'user', content: request.query }],
...this.options.searchRecency !== undefined ? { search_recency_filter: this.options.searchRecency } : {}, ...this.options.searchRecency !== undefined ? { search_recency_filter: this.options.searchRecency } : {},
}), }),
...exec?.signal ? { signal: exec.signal } : {}, ...signal !== undefined ? { signal } : {},
}) })
} catch (error: unknown) { } catch (error: unknown) {
if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error })
@@ -148,7 +143,7 @@ export class PerplexitySearchProvider implements WebSearchProvider {
try { try {
const payload = await response.json() as PerplexityResponse const payload = await response.json() as PerplexityResponse
return mapPerplexityResponse(request.query, payload) return mapPerplexityResponse(payload)
} catch (error: unknown) { } catch (error: unknown) {
if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error })
throw new WebError(`Perplexity returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) throw new WebError(`Perplexity returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })

View File

@@ -17,7 +17,6 @@ maybe('PerplexitySearchProvider real API', () => {
maxTokens: PERPLEXITY_DEFAULT_MAX_TOKENS, maxTokens: PERPLEXITY_DEFAULT_MAX_TOKENS,
}) })
const result = await provider.search({ query: 'What is the DeepSeek Harness SDK?', maxResults: 5 }) const result = await provider.search({ query: 'What is the DeepSeek Harness SDK?', maxResults: 5 })
expect(result.providerId).toBe('perplexity')
expect(result.content ?? '').not.toBe('') expect(result.content ?? '').not.toBe('')
for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//)
}, 30_000) }, 30_000)

View File

@@ -20,7 +20,7 @@ afterEach(() => {
describe('Perplexity response mapping', () => { describe('Perplexity response mapping', () => {
it('maps the answer and prefers structured search_results', () => { it('maps the answer and prefers structured search_results', () => {
const result = mapPerplexityResponse('q', { const result = mapPerplexityResponse({
choices: [{ message: { content: 'the answer' } }], choices: [{ message: { content: 'the answer' } }],
search_results: [ search_results: [
{ url: 'https://a.test', title: 'A', snippet: 'snip', date: '2026-02-02' }, { url: 'https://a.test', title: 'A', snippet: 'snip', date: '2026-02-02' },
@@ -29,8 +29,6 @@ describe('Perplexity response mapping', () => {
citations: ['https://ignored.test'], citations: ['https://ignored.test'],
}) })
expect(result).toEqual({ expect(result).toEqual({
providerId: PERPLEXITY_PROVIDER_ID,
query: 'q',
content: 'the answer', content: 'the answer',
sources: [ sources: [
{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-02-02' }, { url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-02-02' },
@@ -41,7 +39,7 @@ describe('Perplexity response mapping', () => {
}) })
it('falls back to URL-only citations when search_results is absent', () => { it('falls back to URL-only citations when search_results is absent', () => {
const result = mapPerplexityResponse('q', { const result = mapPerplexityResponse({
choices: [{ message: { content: 'answer' } }], choices: [{ message: { content: 'answer' } }],
citations: ['https://a.test', 'https://b.test'], citations: ['https://a.test', 'https://b.test'],
}) })
@@ -49,43 +47,39 @@ describe('Perplexity response mapping', () => {
}) })
it('omits content when the answer is empty or missing', () => { it('omits content when the answer is empty or missing', () => {
expect(mapPerplexityResponse('q', { citations: [] }).content).toBeUndefined() expect(mapPerplexityResponse({ citations: [] }).content).toBeUndefined()
expect(mapPerplexityResponse('q', { choices: [{ message: { content: '' } }] }).content).toBeUndefined() expect(mapPerplexityResponse({ choices: [{ message: { content: '' } }] }).content).toBeUndefined()
expect(mapPerplexityResponse('q', { choices: [{ message: { content: null } }] }).content).toBeUndefined() expect(mapPerplexityResponse({ choices: [{ message: { content: null } }] }).content).toBeUndefined()
}) })
it('omits null/empty optional source fields', () => { it('omits null/empty optional source fields', () => {
const result = mapPerplexityResponse('q', { const result = mapPerplexityResponse({
search_results: [{ url: 'https://a.test', title: null, snippet: '', date: null }], search_results: [{ url: 'https://a.test', title: null, snippet: '', date: null }],
}) })
expect(result.sources).toEqual([{ url: 'https://a.test' }]) expect(result.sources).toEqual([{ url: 'https://a.test' }])
}) })
it('yields no sources when neither search_results nor citations are present', () => { it('yields no sources when neither search_results nor citations are present', () => {
expect(mapPerplexityResponse('q', { choices: [{ message: { content: 'a' } }] }).sources).toEqual([]) expect(mapPerplexityResponse({ choices: [{ message: { content: 'a' } }] }).sources).toEqual([])
}) })
}) })
describe('PerplexitySearchProvider status', () => { describe('PerplexitySearchProvider availability', () => {
it('is unavailable without a key', () => { it('is unavailable without a key', () => {
expect(new PerplexitySearchProvider({ ...options, apiKey: '' }).status()) expect(new PerplexitySearchProvider({ ...options, apiKey: '' }).available()).toBe(false)
.toEqual({ available: false, reason: 'missing-credential' })
}) })
it('is available with a key', () => { it('is available with a key', () => {
expect(new PerplexitySearchProvider(options).status()).toEqual({ available: true }) expect(new PerplexitySearchProvider(options).available()).toBe(true)
}) })
it('is misconfigured when the base URL is unparseable', () => { it('is misconfigured when the base URL is unparseable', () => {
expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).status()) expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false)
.toEqual({ available: false, reason: 'misconfigured' })
}) })
it('is misconfigured when maxTokens is not a positive integer', () => { it('is misconfigured when maxTokens is not a positive integer', () => {
expect(new PerplexitySearchProvider({ ...options, maxTokens: 0 }).status()) expect(new PerplexitySearchProvider({ ...options, maxTokens: 0 }).available()).toBe(false)
.toEqual({ available: false, reason: 'misconfigured' }) expect(new PerplexitySearchProvider({ ...options, maxTokens: 1.5 }).available()).toBe(false)
expect(new PerplexitySearchProvider({ ...options, maxTokens: 1.5 }).status())
.toEqual({ available: false, reason: 'misconfigured' })
}) })
}) })
@@ -114,7 +108,7 @@ describe('PerplexitySearchProvider request mapping', () => {
const fetchMock = vi.fn(async () => jsonResponse({ citations: [] })) const fetchMock = vi.fn(async () => jsonResponse({ citations: [] }))
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController() const controller = new AbortController()
await new PerplexitySearchProvider(options).search({ query: 'q' }, { signal: controller.signal }) await new PerplexitySearchProvider(options).search({ query: 'q' }, controller.signal)
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(init.signal).toBe(controller.signal) expect(init.signal).toBe(controller.signal)
}) })
@@ -190,7 +184,7 @@ describe('web-search-perplexity plugin registration', () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID }) await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID })
const fiber = await ctx.plugin(perplexityPlugin, { apiKey: 'pplx-key' }) const fiber = await ctx.plugin(perplexityPlugin, { apiKey: 'pplx-key' })
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: PERPLEXITY_PROVIDER_ID }) await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ content: 'a', sources: [] })
await fiber.dispose() await fiber.dispose()
await expect(ctx.web.search({ query: 'q' })) await expect(ctx.web.search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))

View File

@@ -19,8 +19,8 @@ Search and fetch share no request schema and no business logic, but they are del
| Member | Semantics | | Member | Semantics |
|---|---| |---|---|
| `registerSearchProvider(provider)` / `registerFetchProvider(provider)` | Register a backend. Throws `WebError` `WEB_DUPLICATE_PROVIDER` on a duplicate id within that capability kind. Returns a disposer. Disposed with the calling fiber. | | `registerSearchProvider(provider)` / `registerFetchProvider(provider)` | Register a backend. Throws `WebError` `WEB_DUPLICATE_PROVIDER` on a duplicate id within that capability kind. Returns a disposer. Disposed with the calling fiber. |
| `search(request, exec?)` | Resolve the search provider and run one search. Enforces `request.maxResults` on the result (truncates `sources[]`, sets `truncated`). Throws `WebError` when the capability cannot run. | | `search(request, signal?)` | Resolve the search provider and run one search. Enforces `request.maxResults` on the result (truncates `sources[]`, sets `truncated`). Throws `WebError` when the capability cannot run. |
| `fetch(request, exec?)` | Resolve the fetch provider and retrieve one URL. A non-2xx response is a result, not a throw. Throws `WebError` for failures to safely retrieve or represent the resource. | | `fetch(request, signal?)` | Resolve the fetch provider and retrieve one URL. A non-2xx response is a result, not a throw. Throws `WebError` for failures to safely retrieve or represent the resource. |
Providers register **capabilities**, not tools. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation. Providers register **capabilities**, not tools. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation.
@@ -30,18 +30,18 @@ Selection never depends on registration, config, or HMR order. A capability has
| Situation | Execution | | Situation | Execution |
|---|---| |---|---|
| configured id registered and `status().available` | runs that provider | | configured id registered and `available()` | runs that provider |
| configured id not registered | `WEB_PROVIDER_CONFIGURED_MISSING` | | configured id not registered | `WEB_PROVIDER_CONFIGURED_MISSING` |
| configured id registered but unavailable | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` | | configured id registered but unavailable | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
| no id, exactly one registered usable provider | runs it | | no id, exactly one registered usable provider | runs it |
| no id, no usable provider | `WEB_PROVIDER_UNAVAILABLE` | | no id, no usable provider | `WEB_PROVIDER_UNAVAILABLE` |
| no id, multiple usable providers | `WEB_PROVIDER_AMBIGUOUS` | | no id, multiple usable providers | `WEB_PROVIDER_AMBIGUOUS` |
The failure branches throw `WebError`, whose structured code (plus message detail — the missing id, the ambiguous candidate set) is the surface callers route on. A provider's own `status()` is a cheap local check (credential presence, parseable config) that feeds this execution-time selection and **must not make network calls**; `dsh-tool-web` never calls a provider's `status()` — it executes through `ctx.web.search()`/`fetch()` and routes on the thrown codes, so provider selection has one owner. The failure branches throw `WebError`, whose structured code (plus message detail — the missing id, the ambiguous candidate set) is the surface callers route on. A provider's own `available()` is a cheap local check (credential presence, parseable config) that feeds this execution-time selection and **must not make network calls**; `dsh-tool-web` never calls it — the tool executes through `ctx.web.search()`/`fetch()` and routes on the thrown codes, so provider selection has one owner.
## Vocabulary ## Vocabulary
`WebSearchRequest` (`query`, `maxResults?`) → `WebSearchResult` (`providerId`, `query`, `content?`, `sources[]`, `truncated`); each `WebSearchSource` has a required `url` and optional `title`/`snippet`/`publishedAt` (Perplexity citations may be URL-only). `WebFetchRequest` (`url`, `timeoutMs?`) → `WebFetchResult` (`providerId`, final `url`, `statusCode`, `body`, `truncated`); `WebFetchBody` is a CLOSED discriminated union (`html` | `text`) owned here — consumers `switch` to exhaustiveness so a new kind breaks their compilation until handled. See `src/types.ts` for the full contracts and the `WebError` code taxonomy. `WebSearchRequest` (`query`, `maxResults?`) → `WebSearchResult` (`content?`, `sources[]`, `truncated`); each `WebSearchSource` has a required `url` and optional `title`/`snippet`/`publishedAt` (Perplexity citations may be URL-only). `WebFetchRequest` (`url`) → `WebFetchResult` (final `url`, `statusCode`, `body`, `truncated`); cancellation is a direct optional `AbortSignal` argument to `search()`/`fetch()`. `WebFetchBody` is a CLOSED discriminated union (`html` | `text`) owned here — consumers `switch` to exhaustiveness so a new kind breaks their compilation until handled. See `src/types.ts` for the full contracts and the `WebError` code taxonomy.
## Model Experience ## Model Experience

View File

@@ -9,11 +9,9 @@
import { Context, Service } from 'cordis' import { Context, Service } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
import type { import type {
WebExecContext,
WebFetchProvider, WebFetchProvider,
WebFetchRequest, WebFetchRequest,
WebFetchResult, WebFetchResult,
WebProviderStatus,
WebSearchProvider, WebSearchProvider,
WebSearchRequest, WebSearchRequest,
WebSearchResult, WebSearchResult,
@@ -24,12 +22,10 @@ export {
WebError, WebError,
} from './types.ts' } from './types.ts'
export type { export type {
WebExecContext,
WebFetchBody, WebFetchBody,
WebFetchProvider, WebFetchProvider,
WebFetchRequest, WebFetchRequest,
WebFetchResult, WebFetchResult,
WebProviderStatus,
WebSearchProvider, WebSearchProvider,
WebSearchRequest, WebSearchRequest,
WebSearchResult, WebSearchResult,
@@ -67,7 +63,7 @@ export interface WebServiceConfig {
* The web access service. Registered as `ctx.web` (one instance per context). * The web access service. Registered as `ctx.web` (one instance per context).
* *
* Selection semantics (resolved at execution time, never order-dependent): * Selection semantics (resolved at execution time, never order-dependent):
* - A configured id that is registered and `status().available` → that provider. * - A configured id that is registered and `available()` → that provider.
* - A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`. * - A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.
* - A configured id registered but unavailable → * - A configured id registered but unavailable →
* `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. * `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
@@ -138,15 +134,15 @@ export class WebService extends Service {
* capability cannot run. The seam enforces `request.maxResults` on the result: * capability cannot run. The seam enforces `request.maxResults` on the result:
* if the provider over-returns, `sources[]` is truncated and `truncated` set. * if the provider over-returns, `sources[]` is truncated and `truncated` set.
* @param request - the query plus result-shaping options. * @param request - the query plus result-shaping options.
* @param exec - the tool-execution context, forwarded to the provider. * @param signal - optional cancellation signal forwarded to the provider.
* @returns the provider's results, capped to `request.maxResults`. * @returns the provider's results, capped to `request.maxResults`.
*/ */
async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> { async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
const provider = resolveProvider({ const provider = resolveProvider({
providers: this.searchProviders, providers: this.searchProviders,
...this.searchProviderId !== undefined ? { configuredId: this.searchProviderId } : {}, ...this.searchProviderId !== undefined ? { configuredId: this.searchProviderId } : {},
}) })
const result = await provider.search(request, exec) const result = await provider.search(request, signal)
return capSources(result, request.maxResults) return capSources(result, request.maxResults)
} }
@@ -155,21 +151,21 @@ export class WebService extends Service {
* call time with the selection rules above; throws {@link WebError} when the * call time with the selection rules above; throws {@link WebError} when the
* capability cannot run. A non-2xx response is a result, not a throw. * capability cannot run. A non-2xx response is a result, not a throw.
* @param request - the URL plus retrieval options. * @param request - the URL plus retrieval options.
* @param exec - the tool-execution context, forwarded to the provider. * @param signal - optional cancellation signal forwarded to the provider.
* @returns the retrieval outcome; non-2xx responses resolve descriptively. * @returns the retrieval outcome; non-2xx responses resolve descriptively.
*/ */
async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> { async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> {
const provider = resolveProvider({ const provider = resolveProvider({
providers: this.fetchProviders, providers: this.fetchProviders,
...this.fetchProviderId !== undefined ? { configuredId: this.fetchProviderId } : {}, ...this.fetchProviderId !== undefined ? { configuredId: this.fetchProviderId } : {},
}) })
return provider.fetch(request, exec) return provider.fetch(request, signal)
} }
} }
interface ResolvableProvider { interface ResolvableProvider {
readonly id: string readonly id: string
status(): WebProviderStatus available(): boolean
} }
/** Resolve the selected provider or throw the matching {@link WebError}. */ /** Resolve the selected provider or throw the matching {@link WebError}. */
@@ -180,12 +176,12 @@ function resolveProvider<P extends ResolvableProvider>(selection: Selection<P>):
if (!provider) { if (!provider) {
throw new WebError(`configured web provider "${configuredId}" is not registered`, 'WEB_PROVIDER_CONFIGURED_MISSING') throw new WebError(`configured web provider "${configuredId}" is not registered`, 'WEB_PROVIDER_CONFIGURED_MISSING')
} }
if (!provider.status().available) { if (!provider.available()) {
throw new WebError(`configured web provider "${configuredId}" is registered but unavailable`, 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE') throw new WebError(`configured web provider "${configuredId}" is registered but unavailable`, 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE')
} }
return provider return provider
} }
const usable = [...providers.values()].filter(provider => provider.status().available) const usable = [...providers.values()].filter(provider => provider.available())
const [single] = usable const [single] = usable
if (single === undefined) { if (single === undefined) {
throw new WebError('no usable web provider is registered', 'WEB_PROVIDER_UNAVAILABLE') throw new WebError('no usable web provider is registered', 'WEB_PROVIDER_UNAVAILABLE')

View File

@@ -7,19 +7,6 @@
import { HarnessError } from '@deepseek-ai/dsh-llm' import { HarnessError } from '@deepseek-ai/dsh-llm'
/**
* Execution control threaded from the tool layer through the seam into a
* provider's network requests, stream readers, and expensive decoding. It is
* NOT business input: the first version carries only `signal` so `tool-web` can
* propagate turn cancellation, tool timeout, and agent disposal. It deliberately
* does NOT carry `ToolExecution`, which would make `dsh-web` depend on
* `dsh-tools`.
*/
export interface WebExecContext {
/** Abort signal a provider must honor for its network/decoding work. */
readonly signal?: AbortSignal
}
/** /**
* What one search-capable backend can return. The model-facing argument is just * What one search-capable backend can return. The model-facing argument is just
* a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged * a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged
@@ -44,10 +31,6 @@ export interface WebSearchRequest {
* when it cut `sources[]` down to `maxResults`. * when it cut `sources[]` down to `maxResults`.
*/ */
export interface WebSearchResult { export interface WebSearchResult {
/** Id of the provider that produced this result. */
readonly providerId: string
/** Echo of the query the provider answered. */
readonly query: string
/** Optional provider-generated answer text, search context, or summary. */ /** Optional provider-generated answer text, search context, or summary. */
readonly content?: string readonly content?: string
/** Citeable sources, already truncated to the request's `maxResults`. */ /** Citeable sources, already truncated to the request's `maxResults`. */
@@ -71,14 +54,13 @@ export interface WebSearchSource {
} }
/** /**
* What one fetch-capable backend is asked to retrieve. `timeoutMs` is an * What one fetch-capable backend is asked to retrieve. The request deliberately
* optional positive hint the provider caps. The request deliberately omits * omits timeout, format, prompt, and extraction controls: cancellation is a
* `format`, `prompt`, and extraction controls — those are presentation or * direct execution argument, while presentation and higher-level LLM concerns
* higher-level LLM concerns, not safe-retrieval inputs. * belong outside safe retrieval.
*/ */
export interface WebFetchRequest { export interface WebFetchRequest {
readonly url: string readonly url: string
readonly timeoutMs?: number
} }
/** /**
@@ -88,8 +70,6 @@ export interface WebFetchRequest {
* represent the resource. * represent the resource.
*/ */
export interface WebFetchResult { export interface WebFetchResult {
/** Id of the provider that produced this result. */
readonly providerId: string
/** The final URL after allowed redirects (the request URL is in the request). */ /** The final URL after allowed redirects (the request URL is in the request). */
readonly url: string readonly url: string
/** HTTP status code of the fetched response. */ /** HTTP status code of the fetched response. */
@@ -113,18 +93,6 @@ export type WebFetchBody =
| { readonly kind: 'html'; readonly content: string } | { readonly kind: 'html'; readonly content: string }
| { readonly kind: 'text'; readonly content: string } | { readonly kind: 'text'; readonly content: string }
/**
* Whether one concrete provider implementation is usable, by cheap local checks
* only (credential presence, parseable endpoint config). A provider `status()`
* must NOT make network calls. It is an input to execution-time selection, not
* a health system: `WebService.search()`/`fetch()` read it to pick a usable
* provider, and selection failure surfaces as the structured {@link WebError}
* codes callers route on.
*/
export type WebProviderStatus =
| { readonly available: true }
| { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
/** /**
* A search-capable backend. Registered with `ctx.web.registerSearchProvider`. * A search-capable backend. Registered with `ctx.web.registerSearchProvider`.
* `id` is a stable string, unique within the search capability kind. * `id` is a stable string, unique within the search capability kind.
@@ -132,9 +100,9 @@ export type WebProviderStatus =
export interface WebSearchProvider { export interface WebSearchProvider {
readonly id: string readonly id: string
/** Cheap local usability check; must not make network calls. */ /** Cheap local usability check; must not make network calls. */
status(): WebProviderStatus available(): boolean
/** Run one search; honor `exec.signal` for cancellation. */ /** Run one search; honor `signal` for cancellation. */
search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
} }
/** /**
@@ -144,9 +112,9 @@ export interface WebSearchProvider {
export interface WebFetchProvider { export interface WebFetchProvider {
readonly id: string readonly id: string
/** Cheap local usability check; must not make network calls. */ /** Cheap local usability check; must not make network calls. */
status(): WebProviderStatus available(): boolean
/** Retrieve one URL; honor `exec.signal` for cancellation. */ /** Retrieve one URL; honor `signal` for cancellation. */
fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
} }
/** /**

View File

@@ -4,7 +4,6 @@ import WebService, {
WebError, WebError,
type WebFetchProvider, type WebFetchProvider,
type WebFetchResult, type WebFetchResult,
type WebProviderStatus,
type WebSearchProvider, type WebSearchProvider,
type WebSearchRequest, type WebSearchRequest,
type WebSearchResult, type WebSearchResult,
@@ -13,25 +12,25 @@ import WebService, {
/** A scripted search provider for contract tests. */ /** A scripted search provider for contract tests. */
function makeSearchProvider( function makeSearchProvider(
id: string, id: string,
status: WebProviderStatus, available: boolean,
search: (request: WebSearchRequest) => Promise<WebSearchResult>, search: (request: WebSearchRequest) => Promise<WebSearchResult>,
): WebSearchProvider { ): WebSearchProvider {
return { id, status: () => status, search: request => search(request) } return { id, available: () => available, search: request => search(request) }
} }
function makeFetchProvider(id: string, status: WebProviderStatus, result: WebFetchResult): WebFetchProvider { function makeFetchProvider(id: string, available: boolean, result: WebFetchResult): WebFetchProvider {
return { id, status: () => status, fetch: () => Promise.resolve(result) } return { id, available: () => available, fetch: () => Promise.resolve(result) }
} }
const available: WebProviderStatus = { available: true } const available = true
const unavailable: WebProviderStatus = { available: false, reason: 'missing-credential' } const unavailable = false
function searchResult(providerId: string, overrides: Partial<WebSearchResult> = {}): WebSearchResult { function searchResult(marker: string, overrides: Partial<WebSearchResult> = {}): WebSearchResult {
return { providerId, query: 'q', sources: [], truncated: false, ...overrides } return { content: marker, sources: [], truncated: false, ...overrides }
} }
function fetchResult(providerId: string): WebFetchResult { function fetchResult(marker: string): WebFetchResult {
return { providerId, url: 'https://example.com', statusCode: 200, body: { kind: 'text', content: 'hi' }, truncated: false } return { url: 'https://example.com', statusCode: 200, body: { kind: 'text', content: marker }, truncated: false }
} }
/** Mount a WebService on a fresh root context with the given config. */ /** Mount a WebService on a fresh root context with the given config. */
@@ -46,7 +45,7 @@ describe('WebService registration', () => {
const { web } = await mountWeb() const { web } = await mountWeb()
const dispose = web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) const dispose = web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' }) await expect(web.search({ query: 'q' })).resolves.toMatchObject({ content: 'exa' })
dispose() dispose()
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' })) await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
@@ -70,7 +69,7 @@ describe('WebService registration', () => {
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) inner.web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
}, { inject: ['web'] })) }, { inject: ['web'] }))
await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' }) await expect(web.search({ query: 'q' })).resolves.toMatchObject({ content: 'exa' })
await fiber.dispose() await fiber.dispose()
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' })) await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
}) })
@@ -111,26 +110,26 @@ describe('WebService execution resolution', () => {
const { web } = await mountWeb({ searchProvider: 'perplexity' }) const { web } = await mountWeb({ searchProvider: 'perplexity' })
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' }) await expect(web.search({ query: 'q' })).resolves.toMatchObject({ content: 'perplexity' })
}) })
it('ignores unusable providers when auto-selecting', async () => { it('ignores unusable providers when auto-selecting', async () => {
const { web } = await mountWeb() const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
web.registerSearchProvider(makeSearchProvider('perplexity', unavailable, () => Promise.resolve(searchResult('perplexity')))) web.registerSearchProvider(makeSearchProvider('perplexity', unavailable, () => Promise.resolve(searchResult('perplexity'))))
await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' }) await expect(web.search({ query: 'q' })).resolves.toMatchObject({ content: 'exa' })
}) })
it('does not let registration order change auto-selection', async () => { it('does not let registration order change auto-selection', async () => {
const a = await mountWeb() const a = await mountWeb()
a.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) a.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
a.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) a.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
await expect(a.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' }) await expect(a.web.search({ query: 'q' })).resolves.toMatchObject({ content: 'perplexity' })
const b = await mountWeb() const b = await mountWeb()
b.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) b.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
b.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) b.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
await expect(b.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' }) await expect(b.web.search({ query: 'q' })).resolves.toMatchObject({ content: 'perplexity' })
}) })
it('runs the selected provider and returns its result', async () => { it('runs the selected provider and returns its result', async () => {
@@ -139,7 +138,6 @@ describe('WebService execution resolution', () => {
searchResult('exa', { content: 'answer', sources: [{ url: 'https://a' }] }), searchResult('exa', { content: 'answer', sources: [{ url: 'https://a' }] }),
))) )))
const result = await web.search({ query: 'q' }) const result = await web.search({ query: 'q' })
expect(result.providerId).toBe('exa')
expect(result.content).toBe('answer') expect(result.content).toBe('answer')
expect(result.sources).toEqual([{ url: 'https://a' }]) expect(result.sources).toEqual([{ url: 'https://a' }])
}) })
@@ -149,11 +147,11 @@ describe('WebService execution resolution', () => {
const seen: (AbortSignal | undefined)[] = [] const seen: (AbortSignal | undefined)[] = []
web.registerSearchProvider({ web.registerSearchProvider({
id: 'exa', id: 'exa',
status: () => available, available: () => available,
search: (_request, exec) => { seen.push(exec?.signal); return Promise.resolve(searchResult('exa')) }, search: (_request, signal) => { seen.push(signal); return Promise.resolve(searchResult('exa')) },
}) })
const controller = new AbortController() const controller = new AbortController()
await web.search({ query: 'q' }, { signal: controller.signal }) await web.search({ query: 'q' }, controller.signal)
expect(seen[0]).toBe(controller.signal) expect(seen[0]).toBe(controller.signal)
}) })
}) })
@@ -195,7 +193,7 @@ describe('WebService fetch capability', () => {
const { web } = await mountWeb() const { web } = await mountWeb()
web.registerFetchProvider(makeFetchProvider('local-http', available, fetchResult('local-http'))) web.registerFetchProvider(makeFetchProvider('local-http', available, fetchResult('local-http')))
const result = await web.fetch({ url: 'https://example.com' }) const result = await web.fetch({ url: 'https://example.com' })
expect(result.providerId).toBe('local-http') expect(result.body.content).toBe('local-http')
expect(result.statusCode).toBe(200) expect(result.statusCode).toBe(200)
}) })

38
pnpm-lock.yaml generated
View File

@@ -1164,6 +1164,16 @@ importers:
specifier: ^4.0.0-rc.7 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) version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/support/loader-smoke:
dependencies:
tsx:
specifier: ^4.22.4
version: 4.22.4
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/support/subagent-mock: packages/support/subagent-mock:
dependencies: dependencies:
schemastery: schemastery:
@@ -1421,6 +1431,31 @@ importers:
specifier: ^4.0.0-rc.7 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) version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/ui/stdio:
dependencies:
schemastery:
specifier: ^3.18.0
version: 3.18.0
devDependencies:
'@cordisjs/plugin-loader':
specifier: workspace:^
version: link:../../../vendor/loader
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-user-interaction':
specifier: workspace:^
version: link:../user-interaction
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
packages/ui/stdio-agent: packages/ui/stdio-agent:
devDependencies: devDependencies:
'@cordisjs/plugin-include': '@cordisjs/plugin-include':
@@ -1450,6 +1485,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-jsonl': '@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^ specifier: workspace:^
version: link:../../session-persistence/session-persistence-jsonl version: link:../../session-persistence/session-persistence-jsonl
'@deepseek-ai/dsh-stdio':
specifier: workspace:^
version: link:../stdio
'@deepseek-ai/dsh-system-prompt': '@deepseek-ai/dsh-system-prompt':
specifier: workspace:^ specifier: workspace:^
version: link:../../core/system-prompt version: link:../../core/system-prompt

View File

@@ -146,7 +146,6 @@
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" }, { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" }, { "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/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/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": "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": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" },

View File

@@ -56,6 +56,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/subagent/subagent-subprocess': { kind: 'indirect', reason: 'Only process-based subagent backends compose a child model request.' }, '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/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' },
'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' }, '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.' }, 'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
'packages/support/subagent-mock': { kind: 'indirect', reason: 'Only dsh-tool-subagent renders its configured test outcome.' }, 'packages/support/subagent-mock': { kind: 'indirect', reason: 'Only dsh-tool-subagent renders its configured test outcome.' },
'packages/ui/acp-agent': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-core and dsh-acp.' }, 'packages/ui/acp-agent': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-core and dsh-acp.' },

View File

@@ -62,9 +62,11 @@
{ "path": "./packages/ui/app-boot" }, { "path": "./packages/ui/app-boot" },
{ "path": "./packages/ui/jsonrpc" }, { "path": "./packages/ui/jsonrpc" },
{ "path": "./packages/ui/jsonrpc-agent" }, { "path": "./packages/ui/jsonrpc-agent" },
{ "path": "./packages/ui/stdio" },
{ "path": "./packages/ui/stdio-agent" }, { "path": "./packages/ui/stdio-agent" },
{ "path": "./packages/support/llm-replay" }, { "path": "./packages/support/llm-replay" },
{ "path": "./packages/support/acp-snapshot" }, { "path": "./packages/support/acp-snapshot" },
{ "path": "./packages/support/loader-smoke" },
{ "path": "./packages/subagent/subagent" }, { "path": "./packages/subagent/subagent" },
{ "path": "./packages/support/subagent-mock" }, { "path": "./packages/support/subagent-mock" },
{ "path": "./packages/subagent/tool-subagent" }, { "path": "./packages/subagent/tool-subagent" },

View File

@@ -73,9 +73,11 @@
{ "path": "./packages/ui/app-boot" }, { "path": "./packages/ui/app-boot" },
{ "path": "./packages/ui/jsonrpc" }, { "path": "./packages/ui/jsonrpc" },
{ "path": "./packages/ui/jsonrpc-agent" }, { "path": "./packages/ui/jsonrpc-agent" },
{ "path": "./packages/ui/stdio" },
{ "path": "./packages/ui/stdio-agent" }, { "path": "./packages/ui/stdio-agent" },
{ "path": "./packages/support/llm-replay" }, { "path": "./packages/support/llm-replay" },
{ "path": "./packages/support/acp-snapshot" }, { "path": "./packages/support/acp-snapshot" },
{ "path": "./packages/support/loader-smoke" },
{ "path": "./packages/subagent/subagent" }, { "path": "./packages/subagent/subagent" },
{ "path": "./packages/support/subagent-mock" }, { "path": "./packages/support/subagent-mock" },
{ "path": "./packages/subagent/tool-subagent" }, { "path": "./packages/subagent/tool-subagent" },