From 0236a123242f8676fe754450cde6c17ff321dde4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:54:30 +0800 Subject: [PATCH 01/13] refactor: prune core tool and prompt surface --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/tools.md | 3 +- .../2026-06-18-session-surface.md | 2 +- .../2026-07-07-tool-call-timeout-policy.md | 3 +- ...-12-simplify-session-log-representation.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent-loop/src/loop.ts | 8 +--- .../agent-loop/tests/review-fixes.spec.ts | 6 +-- packages/core/session/README.md | 2 +- packages/core/session/src/surface.ts | 31 ++++------------ .../core/session/tests/derived-cache.spec.ts | 15 +------- packages/core/session/tests/surface.spec.ts | 15 +------- packages/core/system-prompt/src/index.ts | 2 +- packages/core/tools/README.md | 2 +- packages/core/tools/src/index.ts | 20 +++------- packages/core/tools/tests/scoped.spec.ts | 4 +- packages/core/tools/tests/tools.spec.ts | 37 ++++--------------- .../invariants/tests/invariants.spec.ts | 6 +-- packages/timeout/timeout-policy/src/index.ts | 7 +--- .../tests/timeout-policy.spec.ts | 18 ++------- 21 files changed, 50 insertions(+), 139 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9d4b8038b5..f785fdf337 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -994,7 +994,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:401`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:400`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-user-approval` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0aea3f1589..28d1904ecf 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -276,7 +276,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:493`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:492`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index d7e0464f01..36c59aea17 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -137,7 +137,6 @@ type ToolGuard = (execution: Readonly) => string | undefined ```ts type-equiv interface ToolExecutionResult { - callId: CallId content: ContentBlock[] 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. 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`: diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index 4c8b5a81e3..315771e46e 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -31,7 +31,7 @@ export type SurfaceOp = ### SurfaceManager: delta-based, not full rebuild -A `SurfaceManager` class (private to `Session`) maintains the cached linked list. It tracks `_lastProcessedSeq` and processes only the **delta** (new events since the last access) rather than rescanning the entire log. Because the log is append-only, prior events never change — full rebuild is only needed after a wholesale log replacement (e.g., seeding). +A `SurfaceManager` owned by `Session` maintains the cached linked list. It tracks `_lastProcessedSeq` and processes only the **delta** (new events since the last access) rather than rescanning the entire log. The seed is fixed before the manager is created and the log is append-only afterward, so prior events never change and no invalidation path is needed. Delta processing is O(1) when no new events and O(new events) when new events arrive. diff --git a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md index 362f5cb5e8..ab6be2efc3 100644 --- a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md +++ b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md @@ -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: ```ts ignore-check -function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecutionResult { +function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { return { - callId, content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }], isError: true, error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, diff --git a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md index f335afafe1..715ce93924 100644 --- a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md +++ b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md @@ -26,7 +26,7 @@ Amend the session-surface and reconstructable-request RFCs where they describe t ## 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. - 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. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b4420e80c7..51a8f52082 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -940,7 +940,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { 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', diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 7a7ca2e28c..38e92d8586 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -899,12 +899,8 @@ async function runStep( }) session.append('tool/result', { turn, step, - // The correlation id MUST be the loop's authoritative call.id (the - // model-transcript id that deriveMessages turns into toolCallId), NOT - // result.callId — a post-execute waterfall listener returning a - // mismatched id would otherwise orphan the call↔result pairing in the - // next model request. A listener-internal id, if ever needed, belongs in - // a separate diagnostic field, never overloaded onto callId. + // Correlation comes from the immutable execution input; the result does + // not duplicate this authoritative transcript identity. callId: call.id, content: result.content, isError: result.isError, diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 5898a82ee2..9eb33672bc 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -1078,8 +1078,7 @@ describe('tool result call identity', () => { // A post-execute listener transforms the result (accept-with-replacement). // 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 - // exec.callId anyway — and uses call.id, the model-transcript id). + // call.id, which is the immutable identity carried by the execution input. ctx.on('tools/post-execute', (exec, _result) => { expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] }) @@ -1089,8 +1088,7 @@ describe('tool result call identity', () => { send(agent, 'use tool') await waitForIdle(ctx, agent) - // The logged tool/result.callId is the originating call.id, NOT the - // listener's wrong id. + // The logged tool/result.callId is the originating call.id. const resultEvent = [...agent.session.events].find(e => e.type === 'tool/result') expect(resultEvent?.type).toBe('tool/result') if (resultEvent?.type === 'tool/result') { diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 97b18b0504..bda9ee860f 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -35,7 +35,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. At this durable boundary, data and surface metadata are lossless-JSON snapshotted and deep-frozen. For an attached session, a reentrant append during dispatch/observer publication rejects, and detach waits for that publication to unwind. Callbacks resolve before the log push; the push is the commit point, after which each observer failure is contained independently. Runtime surface validation covers widened unions and raw seed/load logs. - `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array per call over shared, deep-frozen `Message` objects. Each projection reuses the already deep-frozen content in its durable log event, so no second deep clone is needed and a consumer still cannot mutate logged data. The surface is the single source of derived history — there is no raw-log fallback. - `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: a fresh message wrapper that reuses the event's already frozen content, or `null` when the event produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC). -- `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and by `invalidate()`, never reset, so an incremental consumer comparing generations cannot be fooled. +- `session.surface: SurfaceManager` — the derived surface, lazily built from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal, bumped by every folded `replace`, so an incremental consumer knows when to rebuild. - `session.events` — a cached, frozen array snapshot over deep-frozen events. Repeated reads without an append return the same array; an append invalidates the cache and the next read returns a new snapshot, while earlier snapshots stay unchanged. Neither a cast nor a retained reference can push into the live log or rewrite an accepted event. - `session.seq`, `session.id` — current sequence and readonly typed identity. - `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`. diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 7219856bdb..20b89b27ae 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -73,36 +73,21 @@ export class SurfaceManager { private _nodes: SurfaceNode[] = [] /** Map from event seq → node. */ private _nodeBySeq = new Map() - /** The last processed seq. -1 forces a full rebuild on first access. */ + /** The last processed seq. -1 marks the initial lazy build. */ private _lastProcessedSeq = -1 - /** Rewrite generation — see {@link replaceGeneration}. */ + /** Replacement generation — see {@link replaceGeneration}. */ private _replaceGeneration = 0 constructor(private log: readonly SessionEvent[]) {} /** - * Reset to unprocessed state. Call after the log has been replaced - * wholesale (e.g. after Session seed). Not needed for normal appends — - * those are picked up incrementally. - */ - invalidate(): void { - this._lastProcessedSeq = -1 - this._nodes = [] - this._nodeBySeq.clear() - // A wholesale rebuild is a rewrite: bump the generation so incremental - // consumers (the session's derived-message cache) discard their view. - this._replaceGeneration += 1 - } - - /** - * The surface's rewrite generation: bumped by every folded `replace` op and - * by {@link invalidate}. A replace is the ONE operation that rewrites the - * surface non-monotonically, so an incremental consumer of {@link nodes} - * (the session's derived-message cache) compares this between visits — an - * unchanged generation guarantees every node it has not seen is a pure tail - * append; a changed one means its view must rebuild. Monotonic: it never - * moves backwards, so comparisons cannot be fooled by a re-fold. + * The surface's replacement generation, bumped by every folded `replace` op. + * A replace is the ONE operation that rewrites the surface non-monotonically, + * so an incremental consumer of {@link nodes} (the session's derived-message + * cache) compares this between visits — an unchanged generation guarantees + * every node it has not seen is a pure tail append; a changed one means its + * view must rebuild. */ get replaceGeneration(): number { if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index 46015106c8..66e99de625 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -1,7 +1,7 @@ /** * Derived-message cache tests: the session projects each surface node exactly - * once (O(new nodes) per call), rebuilds on a surface rewrite (replace / - * invalidate — the replaceGeneration signal), returns a fresh array snapshot + * once (O(new nodes) per call), rebuilds on a surface replacement (the + * replaceGeneration signal), returns a fresh array snapshot * per call over shared frozen messages, and stays deep-equal to a from-scratch * replay derivation at every step — the incremental==scratch property the * reconstructability RFC's invariant enforces in dev at request time. @@ -66,17 +66,6 @@ describe('derived-message cache', () => { expect(Object.isFrozen(first[0])).toBe(true) }) - it('rebuilds after surface.invalidate() (the generation covers wholesale rebuilds too)', () => { - const session = new Session(SessionId('cache-invalidate')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - userText(session, 'one') - const before = session.deriveMessages() - session.surface.invalidate() - const after = session.deriveMessages() - expect(after).toEqual(before) - // A rebuild re-projects: fresh objects, same values. - expect(after[0]).not.toBe(before[0]) - }) }) describe('Session.deriveEventMessage — the per-event projection', () => { diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 257828e466..0d64fd6807 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -28,14 +28,6 @@ describe('SurfaceManager', () => { expect(nodes[1]!.next).toBeNull() }) - it('invalidate resets to full rebuild', () => { - const s = surfaceSession() - expect(s.surface.nodes.length).toBe(2) - // After invalidate, the surface should rebuild from scratch on next access. - ;(s.surface).invalidate() - expect(s.surface.nodes.length).toBe(2) // same result, but rebuilt - }) - it('empty surface yields empty nodes', () => { const s = new Session(SessionId('empty')) // Only turn boundaries, no surface nodes. @@ -336,7 +328,7 @@ describe('surface type guards', () => { }) describe('SurfaceManager.replaceGeneration', () => { - it('folds the pending log delta on access and counts replaces and invalidations', () => { + it('folds the pending log delta on access and counts replacements', () => { const s = new Session(SessionId('gen')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) s.append('user/message', { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -350,10 +342,5 @@ describe('SurfaceManager.replaceGeneration', () => { content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' }, }, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq }, sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq] }) expect(s.surface.replaceGeneration).toBe(1) - - // invalidate() is a rewrite too: the generation moves forward (and the - // refold re-counts the replace), never backwards. - s.surface.invalidate() - expect(s.surface.replaceGeneration).toBeGreaterThan(1) }) }) diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index d9d19c7f15..b886b4c774 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -360,7 +360,7 @@ export class SystemPrompt extends Service { private scopedVariableProviders = new Map string | undefined>>() private readonly toolOrder: string[] | undefined - constructor(ctx: Context, public config: Config) { + constructor(ctx: Context, config: Config) { super(ctx, 'systemPrompt') this.toolOrder = validateToolOrder(config.toolOrder) // The harness-owned openers. They live HERE (not on the loop plugin) so a diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index a9411f6cba..d57aeba10b 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -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. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. - `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. -- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ 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. - `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. diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 08edb8d8ae..a44d054c89 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -290,7 +290,7 @@ export interface ToolErrorInfo { * distinguish it from a tool body's own error. */ export class ToolNotFoundError extends HarnessError { - constructor(public readonly toolName: string) { + constructor(toolName: string) { super(`unknown tool "${toolName}"`, 'UNKNOWN_TOOL') this.name = 'ToolNotFoundError' } @@ -298,7 +298,6 @@ export class ToolNotFoundError extends HarnessError { /** The outcome of one tool call. */ export interface ToolExecutionResult { - callId: CallId content: ContentBlock[] isError: boolean /** @@ -918,7 +917,7 @@ export class ToolRegistry extends Service { } } catch (error: unknown) { execution = { ...base, arguments: undefined } - const result = this.materializeFinalResult(toolErrorResult(callId, error)) + const result = this.materializeFinalResult(toolErrorResult(error)) this.notifyResult(execution, result) return result } @@ -928,7 +927,7 @@ export class ToolRegistry extends Service { } catch (error: unknown) { // Outer backstop: a throwing pre/post-execute listener, guard, or the // 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) return result @@ -953,7 +952,6 @@ export class ToolRegistry extends Service { // Every non-grant, including a failed/unavailable approval request, takes // the same deny path and still reaches post-policy plus result observers. const denied: ToolExecutionResult = { - callId: exec.callId, content: [{ type: 'text', text: `Error: ${denialReason}` }], isError: true, } @@ -984,16 +982,12 @@ export class ToolRegistry extends Service { const returned = await tool.execute(exec.arguments, exec) const content = Array.isArray(returned) ? returned : returned.content 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) { - 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) } @@ -1068,7 +1062,6 @@ export class ToolRegistry extends Service { const additionalContext = decision.additionalContext if (decision.kind === 'block') { return { - callId: result.callId, content: decision.feedback, isError: true, ...additionalContext ? { additionalContext } : {}, @@ -1097,10 +1090,9 @@ function createExecutionToken(): 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) return { - callId, content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }], isError: true, ...info ? { error: info } : {}, diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 6599112c2a..d4851cdbd7 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -548,7 +548,6 @@ describe('scoped execution dispatch', () => { expect(reads).toBe(1) expect(result).toEqual({ - callId: CallId('unstable-arguments'), content: [{ type: 'text', text: 'ran:t' }], isError: false, }) @@ -564,10 +563,9 @@ describe('scoped execution dispatch', () => { ctx.on('internal/dispatch', (mode, name) => { if (name === 'tools/result') dispatchModes.push(mode) }) - ctx.on('tools/execute', async (exec, next) => { + ctx.on('tools/execute', async (_exec, next) => { await next() return { - callId: exec.callId, content: [{ type: 'text', text: 'outer failure' }], isError: true, } diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index e74766c699..a2dff84287 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -80,7 +80,7 @@ describe('ToolRegistry', () => { const ctx = await setup() ctx.tools.register(echoTool) 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 () => { @@ -94,7 +94,6 @@ describe('ToolRegistry', () => { }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} }) expect(result).toEqual({ - callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false, 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: {} }) - 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) }) @@ -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 err = new ToolNotFoundError('ghost') expect(err).toBeInstanceOf(HarnessError) expect(err.name).toBe('ToolNotFoundError') expect(err.code).toBe('UNKNOWN_TOOL') - expect(err.toolName).toBe('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() }) 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. expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post']) }) @@ -526,8 +524,8 @@ describe('ToolRegistry', () => { async execute() { dispatched = true; return [] }, }) - ctx.on('tools/execute', async (exec: ToolExecution, _next: () => Promise): Promise => - ({ callId: exec.callId, content: [{ type: 'text', text: 'short-circuited' }], isError: false })) + ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise): Promise => + ({ content: [{ type: 'text', text: 'short-circuited' }], isError: false })) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} }) 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 () => { const ctx = await setup() ctx.tools.register(echoTool) - ctx.on('tools/execute', async exec => ({ - callId: exec.callId, + ctx.on('tools/execute', async () => ({ content: [{ type: 'text', text: 'short-circuited with context' }], isError: false, 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 () => { const ctx = await setup() ctx.tools.register(echoTool) @@ -577,7 +560,6 @@ describe('ToolRegistry', () => { const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ - callId: CallId('c1'), content: [{ type: 'text', text: 'Error: wrapper broke' }], isError: true, }) @@ -593,7 +575,6 @@ describe('ToolRegistry', () => { const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ - callId: CallId('c1'), content: [{ type: 'text', text: 'Error: permission hook broke' }], isError: true, }) @@ -609,7 +590,6 @@ describe('ToolRegistry', () => { const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ - callId: CallId('c1'), content: [{ type: 'text', text: 'Error: post hook broke' }], isError: true, }) @@ -625,7 +605,6 @@ describe('ToolRegistry', () => { const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toMatchObject({ - callId: CallId('c1'), isError: true, error: { name: 'HarnessError', code: 'DENIED' }, }) @@ -1263,7 +1242,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => { }, })) 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', () => { diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index a1e824524b..d82affe4e7 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -869,9 +869,9 @@ describe('scoped-dispatch invariants', () => { ['agent/error', [agent, 1, 0, new Error('x')]], ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], ['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/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], - ['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { 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 }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], + ['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }]], ] for (const [event, args] of rows) { const subject = agent diff --git a/packages/timeout/timeout-policy/src/index.ts b/packages/timeout/timeout-policy/src/index.ts index 2e9ed0d635..49f62f9e81 100644 --- a/packages/timeout/timeout-policy/src/index.ts +++ b/packages/timeout/timeout-policy/src/index.ts @@ -33,7 +33,6 @@ */ import type { Context } from 'cordis' -import type { CallId } from '@deepseek-ai/dsh-llm' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' @@ -57,13 +56,11 @@ export const inject = ['tools'] * 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. * - * @param callId - the timed-out call's id, carried onto the replacement result. * @param timeoutMs - the elapsed budget, rendered into the model-facing message. * @returns the `isError` {@link ToolExecutionResult} with a `TOOL_TIMEOUT` error. */ -export function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecutionResult { +function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { return { - callId, content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }], isError: true, error: { name: 'ToolTimeoutError', code: TOOL_TIMEOUT }, @@ -108,7 +105,7 @@ export function apply(ctx: Context): void { // quiescence; replace whatever it returned (its own abort result) with the // structured TOOL_TIMEOUT the model sees. if (timeoutOf(d.signal, TOOL_TIMEOUT) !== undefined) { - return toolTimeoutResult(exec.callId, timeoutMs) + return toolTimeoutResult(timeoutMs) } return result } finally { diff --git a/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts b/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts index 30a7307515..bd06ed6e16 100644 --- a/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts +++ b/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts @@ -11,9 +11,9 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' 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 { 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. */ 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, async execute() { return [{ type: 'text' as const, text: 'ok' }] } })) 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 () => { @@ -109,7 +109,6 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { await vi.advanceTimersByTimeAsync(150) const result = await pending expect(result).toEqual({ - callId: CallId('c1'), content: [{ type: 'text', text: 'Error: tool call timed out after 100ms' }], isError: true, error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, @@ -140,16 +139,7 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { }) }) -describe('toolTimeoutResult', () => { - 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) - }) - +describe('timeout-policy contract', () => { it('exposes the owned code constant', () => { expect(TOOL_TIMEOUT).toBe('TOOL_TIMEOUT') }) From 8c8b422f1787233bf8485982bb92f206734a9e3f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:37:51 +0800 Subject: [PATCH 02/13] refactor: prune bash implementation surface --- docs/config-catalog.md | 2 +- docs/core-data-structures/bash.md | 1 - packages/bash/bash-local/README.md | 2 + packages/bash/bash-local/src/index.ts | 4 -- packages/bash/bash-local/src/run.ts | 27 ++----- packages/bash/bash-local/tests/run.spec.ts | 16 ++--- packages/bash/bash/src/types.ts | 1 - packages/bash/bash/tests/service.spec.ts | 1 - packages/bash/tool-bash/README.md | 2 + packages/bash/tool-bash/src/index.ts | 65 +---------------- packages/bash/tool-bash/src/render.ts | 70 +++++++++++++++++++ packages/bash/tool-bash/tests/tools.spec.ts | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- 13 files changed, 88 insertions(+), 109 deletions(-) create mode 100644 packages/bash/tool-bash/src/render.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9d4b8038b5..b0391d7a65 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -149,7 +149,7 @@ export interface Config { } ``` -Source: [`packages/bash/bash-local/src/index.ts:29`](../packages/bash/bash-local/src/index.ts) +Source: [`packages/bash/bash-local/src/index.ts:26`](../packages/bash/bash-local/src/index.ts) ## `@deepseek-ai/dsh-bash-sandbox` diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 51f7cb0696..58810ecdda 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -202,7 +202,6 @@ A long-running command started with `start()` is tracked as a `BashTask`. `BashT ```ts type-equiv interface BashTask { readonly id: BashTaskId - readonly command: string status: BashTaskStatus /** Exit code once finished (null = killed by signal / still running). */ exitCode: number | null diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 1ac8a7d06b..fde35d8aa9 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -2,6 +2,8 @@ Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c ` 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 ```yaml diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 6903c06e32..a90bcaf29a 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -22,9 +22,6 @@ import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { DEFAULT_GRACE_MS, runBash } 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). */ export interface Config { /** Default working directory for commands (default: process.cwd()). */ @@ -184,7 +181,6 @@ export class LocalBashExecutor extends BashExecutor { const id = BashTaskId(`bash-${this.nextTaskId++}`) const task: TrackedTask = { id, - command: spec.command, status: 'running', exitCode: null, signal: null, diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index bc4a017dea..c09aa80173 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -209,27 +209,6 @@ export class OutputCollector { 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 * pushed since `fromByte`. When `fromByte` has already slid out of the @@ -270,7 +249,11 @@ export class OutputCollector { } this.spillFd = undefined } - return this.snapshot() + return { + text: Buffer.concat(this.chunks).toString('utf8'), + truncated: this.dropped, + ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {}, + } } } diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index 1d6e93afe3..eef7f0b1e8 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/bash/bash-local/tests/run.spec.ts @@ -2,8 +2,8 @@ import { mkdtempSync, readFileSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { killGroup, OutputCollector, runBash } from '@deepseek-ai/dsh-bash-local' -import type { RunningBash } from '@deepseek-ai/dsh-bash-local' +import { killGroup, OutputCollector, runBash } from '../src/run.ts' +import type { RunningBash } from '../src/run.ts' const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } })) vi.mock('node:fs', async (importOriginal) => { @@ -49,7 +49,7 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise { async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise { const deadline = Date.now() + timeoutMs 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)) } throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`) @@ -300,19 +300,11 @@ describe('OutputCollector', () => { 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', () => { const collector = new OutputCollector(4, 'closefail', spillDir) collector.push(Buffer.from('aaaa')) collector.push(Buffer.from('bbbb')) - expect(collector.snapshot().spillPath).toBeDefined() + expect(collector.readFrom(0).spillPath).toBeDefined() failNextClose.value = true let out: ReturnType diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index 39dbc162c6..5225d6d6b3 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -229,7 +229,6 @@ export type BashTaskStatus = 'running' | 'completed' | 'killed' /** A tracked background task handle. */ export interface BashTask { readonly id: BashTaskId - readonly command: string status: BashTaskStatus /** Exit code once finished (null = killed by signal / still running). */ exitCode: number | null diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts index 94d299f175..bdee15aedd 100644 --- a/packages/bash/bash/tests/service.spec.ts +++ b/packages/bash/bash/tests/service.spec.ts @@ -34,7 +34,6 @@ class StubExecutor extends BashExecutor { start(spec: BashExecSpec): BashTask { const task: BashTask = { id: BashTaskId(`stub-${this.tasks.size + 1}`), - command: spec.command, status: 'running', exitCode: null, signal: null, diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 800de0132c..ac55e342cd 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -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']`). +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. Under a sandboxing executor it additionally contributes the per-agent `env:bash-sandbox` section (order 110) stating each session's EFFECTIVE mode, and the pre-step narrator — see [Per-session mode](#per-session-mode-switching-and-visibility). ## Tools diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 31e6512ae0..2df80b879d 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -68,7 +68,8 @@ import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' 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 { renderResult } from './render.ts' export const name = 'tool-bash' export const inject = ['tools', 'bash', 'systemPrompt'] @@ -185,68 +186,6 @@ function bashDescription(escalationModes: readonly SandboxMode[]): string { + '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 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') -} - // --------------------------------------------------------------------------- // UI presentation (tool-owned). These shape how a UI (e.g. the ACP bridge) // renders a bash call's pending and completed states. They are display-only and diff --git a/packages/bash/tool-bash/src/render.ts b/packages/bash/tool-bash/src/render.ts new file mode 100644 index 0000000000..f8d9398fa1 --- /dev/null +++ b/packages/bash/tool-bash/src/render.ts @@ -0,0 +1,70 @@ +/** + * 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') +} diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 12b4a53958..1e078a6457 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -19,7 +19,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import ApprovalService 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 { renderResult } from '@deepseek-ai/dsh-tool-bash' +import { renderResult } from '../src/render.ts' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-')) @@ -118,7 +118,6 @@ abstract class TestBashExecutor extends BashExecutor { class LossyReadBashExecutor extends TestBashExecutor { private readonly task: BashTask = { id: BashTaskId('bash-lossy'), - command: 'fake', status: 'running', exitCode: null, signal: null, @@ -1062,7 +1061,6 @@ describe('sandbox rendering', () => { class FactsOnlyExecutor extends TestBashExecutor { private readonly task: BashTask = { id: BashTaskId('bash-facts'), - command: 'fake', status: 'completed', exitCode: 1, signal: null, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b4420e80c7..b43a0492b7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -564,7 +564,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { 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;\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;\n sandbox?: BashSandboxInfo;\n}', }, { name: 'BashTaskId', From 419370ea4b7afdac2d3ae1d6d5f7248290823f71 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:45:22 +0800 Subject: [PATCH 03/13] fix: keep bash status rendering and parsing together --- packages/bash/tool-bash/src/index.ts | 35 +-------------------------- packages/bash/tool-bash/src/render.ts | 22 +++++++++++++++++ 2 files changed, 23 insertions(+), 34 deletions(-) diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 2df80b879d..af013de21e 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -69,7 +69,7 @@ import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash' import type { BashTask } from '@deepseek-ai/dsh-bash' -import { renderResult } from './render.ts' +import { parseExitStatus, renderResult } from './render.ts' export const name = 'tool-bash' export const inject = ['tools', 'bash', 'systemPrompt'] @@ -275,39 +275,6 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView | return { card: 'terminal', output: raw, ...parseExitStatus(raw) } } -/** - * Recover the structured exit status from a rendered `renderResult` string — the - * inverse of the status markers it appends. A `[killed by signal: SIG]` marker - * yields `{signal}`; otherwise an `[exit code: N]` marker yields `{exitCode:N}`; - * absent both we report `{exitCode:0}` (a clean run appends no marker — and a - * trapped-timeout run that exits 0 also has none and is accurately exit 0). - * - * Why parse rendered text at all: `presentResult` is replay-safe and on a - * `session/load` the ONLY thing persisted is this content text — the structured - * `BashRunResult` is long gone — so unless the exit were added to the persisted - * event schema (deliberately NOT done; see the terminal-rendering RFC), parsing - * is the only channel. The match is anchored to a LEADING newline + end-of-string - * because `renderResult` always inserts a `\n` before the marker (line ~124) onto - * a non-empty body: a real marker is therefore always its own final line. That - * defeats the common spoof (program output that simply ENDS in `[exit code: 5]` - * with no trailing newline — a clean exit 0 — no longer reads as a failure). - * - * KNOWN RESIDUAL (inherent to the replay-only-sees-text design): a clean exit 0 - * whose body's FINAL line is itself exactly the marker text — `[exit code: N]` - * or `[killed by signal: SIG]`, printed by the program with nothing after — is - * still indistinguishable from a real marker and would show a wrong pill. This is - * display-only (execution and the model-facing text are unaffected) and narrow; - * the complete fix is to persist a structured exit on the result event, which the - * RFC names as the escape hatch. - */ -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). */ 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 } diff --git a/packages/bash/tool-bash/src/render.ts b/packages/bash/tool-bash/src/render.ts index f8d9398fa1..924861bb1e 100644 --- a/packages/bash/tool-bash/src/render.ts +++ b/packages/bash/tool-bash/src/render.ts @@ -68,3 +68,25 @@ export function renderResult( 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 } +} From 3ab35de64f830bda324695cb5ef12cf2269fb835 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 04:17:38 +0800 Subject: [PATCH 04/13] refactor: prune unused web seam fields --- docs/config-catalog.md | 4 +- docs/cordis-catalog/services.md | 8 +-- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/web.md | 16 +---- .../2026-06-24-web-capability-seam.md | 45 ++++-------- .../2026-07-07-tool-call-timeout-policy.md | 2 +- ...drop-unconsumed-web-observation-surface.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 22 ++---- packages/web/tool-web/README.md | 2 +- packages/web/tool-web/src/fetch.ts | 2 +- packages/web/tool-web/src/search.ts | 2 +- .../web/tool-web/tests/integration.spec.ts | 20 ++++-- packages/web/tool-web/tests/tool-web.spec.ts | 68 +++++++++---------- packages/web/web-fetch-local/README.md | 5 +- packages/web/web-fetch-local/src/index.ts | 5 -- packages/web/web-fetch-local/src/provider.ts | 18 ++--- .../web-fetch-local/tests/fetch-local.spec.ts | 16 ++--- packages/web/web-search-deepseek/README.md | 4 +- .../web/web-search-deepseek/src/provider.ts | 24 +++---- .../web-search-deepseek/tests/deepseek.e2e.ts | 1 - .../tests/deepseek.spec.ts | 43 +++++------- packages/web/web-search-exa/README.md | 4 +- packages/web/web-search-exa/src/provider.ts | 25 +++---- packages/web/web-search-exa/tests/exa.e2e.ts | 1 - packages/web/web-search-exa/tests/exa.spec.ts | 29 +++----- packages/web/web-search-perplexity/README.md | 4 +- .../web/web-search-perplexity/src/provider.ts | 23 +++---- .../tests/perplexity.e2e.ts | 1 - .../tests/perplexity.spec.ts | 36 ++++------ packages/web/web/README.md | 10 +-- packages/web/web/src/index.ts | 24 +++---- packages/web/web/src/types.ts | 61 ++++------------- packages/web/web/tests/web.spec.ts | 42 ++++++------ scripts/type-equiv.manifest.json | 1 - 34 files changed, 228 insertions(+), 344 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9d4b8038b5..8dec62dce6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1044,7 +1044,7 @@ export interface WebServiceConfig { } ``` -Source: [`packages/web/web/src/index.ts:68`](../packages/web/web/src/index.ts) +Source: [`packages/web/web/src/index.ts:64`](../packages/web/web/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` @@ -1061,8 +1061,6 @@ export interface Config { maxBodyChars?: number /** Default fetch timeout in milliseconds. */ timeoutMs?: number - /** Upper bound for a per-request timeout override. */ - maxTimeoutMs?: number /** Maximum number of same-origin redirect hops to follow. */ maxRedirects?: number /** `User-Agent` header sent on every request. */ diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0aea3f1589..a65fc16633 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -295,7 +295,7 @@ The web access service. Registered as `ctx.web` (one instance per context). 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 registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. - No id configured, exactly one registered usable provider → that provider. @@ -305,11 +305,11 @@ Selection semantics (resolved at execution time, never order-dependent): ```ts cordis-catalog registerSearchProvider(provider: WebSearchProvider): () => void registerFetchProvider(provider: WebFetchProvider): () => void -async search(request: WebSearchRequest, exec?: WebExecContext): Promise -async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise +async search(request: WebSearchRequest, signal?: AbortSignal): Promise +async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise ``` -Source: [`packages/web/web/src/index.ts:87`](../../packages/web/web/src/index.ts) +Source: [`packages/web/web/src/index.ts:83`](../../packages/web/web/src/index.ts) ## `ctx.workflows` — `WorkflowService` (abstract seam) diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 757c74f399..9f94036cdc 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -30,7 +30,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 | | [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface | | [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | -| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider/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 | > Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts. diff --git a/docs/core-data-structures/web.md b/docs/core-data-structures/web.md index f6bd6406ab..204f78dbf1 100644 --- a/docs/core-data-structures/web.md +++ b/docs/core-data-structures/web.md @@ -25,8 +25,6 @@ interface WebSearchRequest { ```ts type-equiv interface WebSearchResult { - readonly providerId: string - readonly query: string readonly content?: string readonly sources: readonly WebSearchSource[] readonly truncated: boolean @@ -49,7 +47,6 @@ interface WebSearchSource { ```ts type-equiv interface WebFetchRequest { 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 interface WebFetchResult { - readonly providerId: string readonly url: string readonly statusCode: number readonly body: WebFetchBody @@ -73,15 +69,9 @@ type WebFetchBody = | { 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. - -```ts type-equiv -type WebProviderStatus = - | { readonly available: true } - | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' } -``` +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. 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. @@ -91,4 +81,4 @@ Selection never depends on registration, config, or HMR order: a capability has ## The service -`WebService` (`ctx.web`, defined in [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)) is a provider registry plus a provider-selecting execution surface, close to `LlmService`'s shape: `registerSearchProvider`/`registerFetchProvider` (duplicate ids throw `WEB_DUPLICATE_PROVIDER`, return disposers) and `search`/`fetch` (resolve the provider at call time, throw a structured `WebError` when the capability cannot run). Providers issue requests with platform-native `fetch` at the repo's Node floor, mirroring `dsh-llm-deepseek`; the `dsh-web-fetch-local` provider owns safe retrieval (http/https-only, credential rejection, byte/char/timeout/redirect caps, same-origin-only redirects with per-hop re-validation, charset decoding) while `dsh-tool-web` owns presentation (HTML→markdown). SSRF / private-network blocking is deferred (see the RFC) — until it lands, `web_fetch` must not be enabled where it can reach sensitive internal targets. +`WebService` (`ctx.web`, defined in [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)) is a provider registry plus a provider-selecting execution surface, close to `LlmService`'s shape: `registerSearchProvider`/`registerFetchProvider` (duplicate ids throw `WEB_DUPLICATE_PROVIDER`, return disposers) and `search`/`fetch` (resolve the provider at call time, accept a direct optional cancellation signal, and throw a structured `WebError` when the capability cannot run). Providers issue requests with platform-native `fetch` at the repo's Node floor, mirroring `dsh-llm-deepseek`; the `dsh-web-fetch-local` provider owns safe retrieval (http/https-only, credential rejection, byte/char/timeout/redirect caps, same-origin-only redirects with per-hop re-validation, charset decoding) while `dsh-tool-web` owns presentation (HTML→markdown). SSRF / private-network blocking is deferred (see the RFC) — until it lands, `web_fetch` must not be enabled where it can reach sensitive internal targets. diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md index 660b9821ff..0763a771f5 100644 --- a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md @@ -77,52 +77,42 @@ Provider packages depend on `@deepseek-ai/dsh-web` and Cordis. They own credenti ```ts interface WebSearchProvider { readonly id: string - status(): WebProviderStatus - search(request: WebSearchRequest, exec?: WebExecContext): Promise + available(): boolean + search(request: WebSearchRequest, signal?: AbortSignal): Promise } interface WebFetchProvider { readonly id: string - status(): WebProviderStatus - fetch(request: WebFetchRequest, exec?: WebExecContext): Promise + available(): boolean + fetch(request: WebFetchRequest, signal?: AbortSignal): Promise } interface WebService { registerSearchProvider(provider: WebSearchProvider): () => void registerFetchProvider(provider: WebFetchProvider): () => void - search(request: WebSearchRequest, exec?: WebExecContext): Promise - fetch(request: WebFetchRequest, exec?: WebExecContext): Promise -} - -interface WebExecContext { - readonly signal?: AbortSignal + search(request: WebSearchRequest, signal?: AbortSignal): Promise + fetch(request: WebFetchRequest, signal?: AbortSignal): Promise } ``` -`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 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. - -```ts -type WebProviderStatus = - | { readonly available: true } - | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' } -``` +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. Selection must not depend on registration order. Cordis load order, config ordering, and HMR timing are not product semantics. | 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 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 | @@ -184,8 +174,6 @@ interface WebSearchRequest { } interface WebSearchResult { - readonly providerId: string - readonly query: string readonly content?: string readonly sources: readonly WebSearchSource[] 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: - `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. ```ts interface WebFetchRequest { readonly url: string - readonly timeoutMs?: number } interface WebFetchResult { - readonly providerId: string readonly url: string readonly statusCode: number 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` 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. -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. diff --git a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md index 362f5cb5e8..1ce2e9dde6 100644 --- a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md +++ b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md @@ -75,7 +75,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`. -`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. diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md index b6fbded6ac..7f8bf80d97 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md @@ -15,7 +15,7 @@ This mirrors [drop the unconsumed `llm/adapter-change` event](../../implemented/ ## Decision -The event declaration, both emits, and the rollback-before-emit ordering are deleted (the plain `ctx.effect` disposer carries HMR cleanup). `searchStatus()`/`fetchStatus()`/`WebCapabilityStatus` are deleted — the provider-private `status()` stays, since it feeds execution-time selection. The listener-throw rollback test that existed solely for the removed event is gone, and the emission assertions and every status-based assertion are rewritten onto the behavior a real caller observes: a successful `search()`/`fetch()`, or the structured `WebError` codes for unavailable/ambiguous/misconfigured provider sets. The cordis catalog is regenerated; `packages/web/web/README.md`, `packages/web/tool-web/README.md` (the drifted reads-status sentence), [web.md](../../../core-data-structures/web.md), and the web paragraph in [architecture.md](../../../architecture.md) describe the shipped contract; the [web capability seam RFC](../../implemented/architecture/2026-06-24-web-capability-seam.md)'s facts (it specified the event and the status aggregation) are amended per [implemented/AGENTS.md](../AGENTS.md). +The event declaration, both emits, and the rollback-before-emit ordering are deleted (the plain `ctx.effect` disposer carries HMR cleanup). `searchStatus()`/`fetchStatus()`/`WebCapabilityStatus` are deleted — the provider-private availability check stays, since it feeds execution-time selection. The listener-throw rollback test that existed solely for the removed event is gone, and the emission assertions and every status-based assertion are rewritten onto the behavior a real caller observes: a successful `search()`/`fetch()`, or the structured `WebError` codes for unavailable/ambiguous/misconfigured provider sets. The cordis catalog is regenerated; `packages/web/web/README.md`, `packages/web/tool-web/README.md` (the drifted reads-status sentence), [web.md](../../../core-data-structures/web.md), and the web paragraph in [architecture.md](../../../architecture.md) describe the shipped contract; the [web capability seam RFC](../../implemented/architecture/2026-06-24-web-capability-seam.md)'s facts (it specified the event and the status aggregation) are amended per [implemented/AGENTS.md](../AGENTS.md). ## Alternatives considered diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b4420e80c7..013cadcebc 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -221,8 +221,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ 'registerSearchProvider(provider: WebSearchProvider): () => void', 'registerFetchProvider(provider: WebFetchProvider): () => void', - 'async search(request: WebSearchRequest, exec?: WebExecContext): Promise', - 'async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise', + 'async search(request: WebSearchRequest, signal?: AbortSignal): Promise', + 'async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise', ], }, { @@ -994,33 +994,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'UserInteractionProvider', declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise;\n}', }, - { - name: 'WebExecContext', - declaration: 'export interface WebExecContext {\n readonly signal?: AbortSignal;\n}', - }, { name: 'WebFetchBody', declaration: 'export type WebFetchBody = {\n readonly kind: \'html\';\n readonly content: string;\n} | {\n readonly kind: \'text\';\n readonly content: string;\n};', }, { name: 'WebFetchProvider', - declaration: 'export interface WebFetchProvider {\n readonly id: string;\n status(): WebProviderStatus;\n fetch(request: WebFetchRequest, exec?: WebExecContext): Promise;\n}', + declaration: 'export interface WebFetchProvider {\n readonly id: string;\n available(): boolean;\n fetch(request: WebFetchRequest, signal?: AbortSignal): Promise;\n}', }, { 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', - 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}', - }, - { - name: 'WebProviderStatus', - declaration: 'export type WebProviderStatus = {\n readonly available: true;\n} | {\n readonly available: false;\n readonly reason: \'missing-credential\' | \'misconfigured\';\n};', + declaration: 'export interface WebFetchResult {\n readonly url: string;\n readonly statusCode: number;\n readonly body: WebFetchBody;\n readonly truncated: boolean;\n}', }, { name: 'WebSearchProvider', - declaration: 'export interface WebSearchProvider {\n readonly id: string;\n status(): WebProviderStatus;\n search(request: WebSearchRequest, exec?: WebExecContext): Promise;\n}', + declaration: 'export interface WebSearchProvider {\n readonly id: string;\n available(): boolean;\n search(request: WebSearchRequest, signal?: AbortSignal): Promise;\n}', }, { name: 'WebSearchRequest', @@ -1028,7 +1020,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { 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', diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index ab1326a21e..8ae9485aab 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -32,4 +32,4 @@ 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. -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. diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 571ce00797..b803673bc6 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -103,7 +103,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number): void { const input = parseFetchArgs(args) const result = await ctx.web.fetch( { url: input.url }, - exec.signal ? { signal: exec.signal } : undefined, + exec.signal, ) return [{ type: 'text', text: formatFetchOutput(result) }] }, diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index a7587d328b..6db829b7fc 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -113,7 +113,7 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: const input = parseSearchArgs(args) const result = await ctx.web.search( { query: input.query, maxResults }, - exec.signal ? { signal: exec.signal } : undefined, + exec.signal, ) return [{ type: 'text', text: formatSearchOutput(result) }] }, diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts index de804e2bcd..f46f34970f 100644 --- a/packages/web/tool-web/tests/integration.spec.ts +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -136,7 +136,7 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc await tctx.plugin(ToolRegistry) await tctx.plugin(WebService, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID }) // 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) // The tool-call budget is declared by tool-web config, enforced by the policy. tfiber = await tctx.plugin(ToolWeb, { fetchTimeoutMs: 50 }) @@ -158,12 +158,20 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc 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 () => { - // A direct seam caller does not go through tools/execute, so the tool-call + it('the provider backstop still protects a direct provider call (no tool-call policy in that path)', async () => { + // A direct provider caller does not go through tools/execute, so the tool-call // policy never applies; the provider's OWN timeout is the only budget. A - // short per-request hint proves the provider backstop is intact and classifies - // as WEB_FETCH_TIMEOUT (the provider-owned code), never TOOL_TIMEOUT. - const err = await tctx.web.fetch({ url: slowBase, timeoutMs: 50 }).then( + // A second direct provider with a short configured backstop proves the + // provider-owned deadline remains intact and distinct from TOOL_TIMEOUT. + const direct = new WebFetchLocal.LocalFetchProvider({ + 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, (e: unknown) => e as { code?: string }, ) diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 4bb2728df7..a9f66d3746 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -4,7 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' 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 { formatSearchOutput, @@ -18,10 +18,10 @@ import { WEB_SEARCH_MAX_RESULTS, } from '@deepseek-ai/dsh-tool-web' -const available: WebProviderStatus = { available: true } +const available = true -function searchProvider(result: WebSearchResult, status: WebProviderStatus = available): WebSearchProvider { - return { id: 'stub-search', status: () => status, search: () => Promise.resolve(result) } +function searchProvider(result: WebSearchResult, isAvailable = available): WebSearchProvider { + return { id: 'stub-search', available: () => isAvailable, search: () => Promise.resolve(result) } } /** Mount the real registry, seam, and tool-web; return an executor helper. */ @@ -46,7 +46,7 @@ async function mountTools(opts: { describe('search formatting', () => { it('renders content, sources with titles/hostnames, snippets, and a citation reminder', () => { const out = formatSearchOutput({ - providerId: 'p', query: 'q', content: 'an answer', truncated: false, + content: 'an answer', truncated: false, sources: [ { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' }, { url: 'https://b.test/y' }, @@ -59,19 +59,19 @@ describe('search formatting', () => { }) 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.') }) 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).not.toContain('No results found.') expect(out).not.toContain('Sources:') }) 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') }) @@ -88,7 +88,7 @@ describe('search formatting', () => { describe('fetch formatting', () => { it('renders an html body to markdown text with a status header', () => { 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: '

Title

Body text

' }, }) 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', () => { 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' }, }) 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', () => { - 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)') }) }) @@ -209,7 +209,7 @@ describe('tool-web registration', () => { describe('tool-web execution through the real registry', () => { it('executes web_search and formats the result', async () => { 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' }], } 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 () => { - const { ctx, fiber, call } = await mountTools({ search: searchProvider({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }) - ctx.web.registerSearchProvider({ id: 'other', status: () => available, search: () => Promise.resolve({ providerId: 'other', query: 'q', sources: [], truncated: false }) }) + const { ctx, fiber, call } = await mountTools({ search: searchProvider({ sources: [], truncated: false }) }) + ctx.web.registerSearchProvider({ id: 'other', available: () => available, search: () => Promise.resolve({ sources: [], truncated: false }) }) const out = await call('web_search', { query: 'q' }) expect(out.isError).toBe(true) 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 () => { - 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 }) expect(out.isError).toBe(true) 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 () => { - const seen: { request?: { url: string; timeoutMs?: number }; signal?: AbortSignal | undefined } = {} + const seen: { request?: { url: string }; signal?: AbortSignal | undefined } = {} const fetchProvider = { id: 'stub-fetch', - status: () => available, - fetch: (request: { url: string; timeoutMs?: number }, exec?: { signal?: AbortSignal }) => { + available: () => available, + fetch: (request: { url: string }, signal?: AbortSignal) => { seen.request = request - seen.signal = exec?.signal - return Promise.resolve({ providerId: 'stub-fetch', url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false }) + seen.signal = signal + 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 }) @@ -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 () => { - const seen: { signal?: AbortSignal | undefined; passedExec?: boolean } = {} + const seen: { signal?: AbortSignal | undefined; passedSignal?: boolean } = {} const fetchProvider = { id: 'stub-fetch', - status: () => available, - fetch: (request: { url: string }, exec?: { signal?: AbortSignal }) => { - seen.passedExec = exec !== undefined - seen.signal = exec?.signal - return Promise.resolve({ providerId: 'stub-fetch', url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false }) + available: () => available, + fetch: (request: { url: string }, signal?: AbortSignal) => { + seen.passedSignal = signal !== undefined + seen.signal = signal + 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 }) - // 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' } }) expect(out.isError).toBe(false) - expect(seen.passedExec).toBe(false) + expect(seen.passedSignal).toBe(false) expect(seen.signal).toBeUndefined() await fiber.dispose() }) @@ -294,8 +294,8 @@ describe('tool-web execution through the real registry', () => { const seen: { signal?: AbortSignal | undefined } = {} const provider: WebSearchProvider = { id: 'stub-search', - status: () => available, - search: (_request, exec) => { seen.signal = exec?.signal; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }, + available: () => available, + search: (_request, signal) => { seen.signal = signal; return Promise.resolve({ sources: [], truncated: false }) }, } const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) const controller = new AbortController() @@ -310,8 +310,8 @@ describe('searchMaxResults is plugin config', () => { const seen: { maxResults?: number | undefined } = {} const provider: WebSearchProvider = { id: 'stub-search', - status: () => available, - search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }, + available: () => available, + search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ sources: [], truncated: false }) }, } const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) 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 provider: WebSearchProvider = { id: 'stub-search', - status: () => available, - search: request => Promise.resolve({ providerId: 'stub-search', query: request.query, sources, truncated: false }), + available: () => available, + search: () => Promise.resolve({ sources, truncated: false }), } const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider }) const out = await call('web_search', { query: 'q' }) diff --git a/packages/web/web-fetch-local/README.md b/packages/web/web-fetch-local/README.md index 9c2ef0030f..e84ca775f0 100644 --- a/packages/web/web-fetch-local/README.md +++ b/packages/web/web-fetch-local/README.md @@ -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's `timeoutMs`/`maxTimeoutMs` is a **resource backstop** for direct `ctx.web.fetch()` callers and misconfigured deployments — it is NOT the model-facing tool-call budget. The tool-call budget for `web_fetch` is deployment policy owned by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md), which arms a per-call deadline on `exec.signal`. A shipped web-tool deployment sets the provider backstop **above** the `tool-timeout` budget, so the tool-call policy normally wins for model calls (returning `TOOL_TIMEOUT`); when the outer deadline signal reaches this provider first, it classifies as `WEB_ABORTED` and the outer wrapper replaces the result with `TOOL_TIMEOUT`. The provider's own `WEB_FETCH_TIMEOUT` only fires for a direct seam caller whose own budget elapsed. +The provider's configured `timeoutMs` is a **resource backstop** for direct `ctx.web.fetch()` callers and misconfigured deployments — it is NOT the model-facing tool-call budget. The tool-call budget for `web_fetch` is deployment policy owned by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md), which arms a per-call deadline on `exec.signal`. A shipped web-tool deployment sets the provider backstop **above** the `tool-timeout` budget, so the tool-call policy normally wins for model calls (returning `TOOL_TIMEOUT`); when the outer deadline signal reaches this provider first, it classifies as `WEB_ABORTED` and the outer wrapper replaces the result with `TOOL_TIMEOUT`. The provider's own `WEB_FETCH_TIMEOUT` fires when its configured backstop elapses. ## Transport hygiene @@ -26,8 +26,7 @@ The provider's `timeoutMs`/`maxTimeoutMs` is a **resource backstop** for direct | `maxUrlLength` | `2048` | Maximum accepted request URL length. | | `maxResponseBytes` | `5_000_000` | Maximum response body size in bytes. | | `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`). | -| `maxTimeoutMs` | `120_000` | Upper bound for a per-request timeout override (direct callers). | +| `timeoutMs` | `30_000` | Fetch timeout — a resource backstop for direct `ctx.web.fetch()` callers, not the model-facing tool-call budget (that is `dsh-timeout-policy`). | | `maxRedirects` | `5` | Maximum same-origin redirect hops (`0` follows none). | | `userAgent` | `deepseek-harness/…` | `User-Agent` header. | diff --git a/packages/web/web-fetch-local/src/index.ts b/packages/web/web-fetch-local/src/index.ts index b6f97bf0d9..713de4c88a 100644 --- a/packages/web/web-fetch-local/src/index.ts +++ b/packages/web/web-fetch-local/src/index.ts @@ -40,8 +40,6 @@ export interface Config { maxBodyChars?: number /** Default fetch timeout in milliseconds. */ timeoutMs?: number - /** Upper bound for a per-request timeout override. */ - maxTimeoutMs?: number /** Maximum number of same-origin redirect hops to follow. */ maxRedirects?: number /** `User-Agent` header sent on every request. */ @@ -53,7 +51,6 @@ export const Config: z = z.object({ maxResponseBytes: z.number().default(5_000_000), maxBodyChars: z.number().default(100_000), timeoutMs: z.number().default(30_000), - maxTimeoutMs: z.number().default(120_000), maxRedirects: z.number().default(5), userAgent: z.string().default(DEFAULT_USER_AGENT), }) @@ -83,14 +80,12 @@ export function apply(ctx: Context, config: Config): void { assertPositiveFinite('maxResponseBytes', resolved.maxResponseBytes) assertPositiveFinite('maxBodyChars', resolved.maxBodyChars) assertPositiveFinite('timeoutMs', resolved.timeoutMs) - assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs) assertNonNegativeInteger('maxRedirects', resolved.maxRedirects) const limits: LocalFetchLimits = { maxUrlLength: resolved.maxUrlLength, maxResponseBytes: resolved.maxResponseBytes, maxBodyChars: resolved.maxBodyChars, timeoutMs: resolved.timeoutMs, - maxTimeoutMs: resolved.maxTimeoutMs, maxRedirects: resolved.maxRedirects, userAgent: resolved.userAgent, } diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts index ed332c4508..787966dcd3 100644 --- a/packages/web/web-fetch-local/src/provider.ts +++ b/packages/web/web-fetch-local/src/provider.ts @@ -20,8 +20,8 @@ */ import { WebError } from '@deepseek-ai/dsh-web' -import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult, WebProviderStatus } from '@deepseek-ai/dsh-web' -import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult } from '@deepseek-ai/dsh-web' +import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts' /** Resolved provider limits (the plugin's schemastery Config supplies defaults). */ @@ -34,8 +34,6 @@ export interface LocalFetchLimits { maxBodyChars: number /** Default fetch timeout in milliseconds. */ timeoutMs: number - /** Upper bound for a per-request timeout override. */ - maxTimeoutMs: number /** Maximum number of (same-origin) redirect hops to follow. */ maxRedirects: number /** `User-Agent` header sent on every request. */ @@ -52,20 +50,19 @@ export class LocalFetchProvider implements WebFetchProvider { constructor(private readonly limits: LocalFetchLimits) {} /** No credentials to check — an anonymous public fetcher is always usable. */ - status(): WebProviderStatus { - return { available: true } + available(): boolean { + return true } - async fetch(request: WebFetchRequest, exec?: { readonly signal?: AbortSignal }): Promise { - if (exec?.signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED') - const timeoutMs = clampTimeout(request.timeoutMs, this.limits.timeoutMs, this.limits.maxTimeoutMs) + async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise { + if (signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED') // One deadline signal fuses the caller's abort with our own timeout, so the // network request and the streaming read both stop on either. The timeout // abort carries a TimeoutReason we recover afterward to classify the cause // (translateAbortOrNetwork), instead of hand-rolling a controller + timer + // reason-recovery dance. - 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) } @@ -161,7 +158,6 @@ export class LocalFetchProvider implements WebFetchProvider { const body: WebFetchBody = kind === 'html' ? { kind: 'html', content } : { kind: 'text', content } return { - providerId: this.id, url: finalUrl.toString(), statusCode: response.status, body, diff --git a/packages/web/web-fetch-local/tests/fetch-local.spec.ts b/packages/web/web-fetch-local/tests/fetch-local.spec.ts index 7d4c4e683b..e3eb7d30c7 100644 --- a/packages/web/web-fetch-local/tests/fetch-local.spec.ts +++ b/packages/web/web-fetch-local/tests/fetch-local.spec.ts @@ -12,7 +12,6 @@ const limits: LocalFetchLimits = { maxResponseBytes: 5_000_000, maxBodyChars: 100_000, timeoutMs: 5_000, - maxTimeoutMs: 10_000, maxRedirects: 5, userAgent: 'test-agent/1.0', } @@ -82,7 +81,7 @@ describe('LocalFetchProvider success', () => { it('fetches a text body', async () => { handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello world') } 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.body).toEqual({ kind: 'text', content: 'hello world' }) expect(result.truncated).toBe(false) @@ -287,14 +286,14 @@ describe('LocalFetchProvider invalid URLs and abort', () => { it('honors a pre-aborted signal', async () => { const controller = new AbortController() 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' })) }) it('aborts an in-flight fetch via the signal', async () => { handler = (_req, _res) => { /* never responds */ } const controller = new AbortController() - const promise = provider().fetch({ url: base }, { signal: controller.signal }) + const promise = provider().fetch({ url: base }, controller.signal) controller.abort() 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' })) }) - 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', () => { @@ -378,7 +372,7 @@ describe('web-fetch-local plugin registration', () => { await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) const fiber = await ctx.plugin(fetchPlugin, {}) await expect(ctx.web.fetch({ url: `${base}/` })) - .resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 }) + .resolves.toMatchObject({ statusCode: 200 }) await fiber.dispose() await expect(ctx.web.fetch({ url: `${base}/` })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) @@ -421,7 +415,7 @@ describe('web-fetch-local plugin registration', () => { await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 }) await expect(ctx.web.fetch({ url: `${base}/` })) - .resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 }) + .resolves.toMatchObject({ statusCode: 200 }) await fiber.dispose() }) }) diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index 41b000d26a..6d65cbec5e 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -16,8 +16,8 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`: | 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). | -| `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`. | +| `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 the provider unavailable. | | `model` | `deepseek-v4-flash` | Anthropic-format model name. | | `apiVersion` | `2023-06-01` | `anthropic-version` header value. | | `maxTokens` | `4096` | Positive-integer upper bound on generated tokens for the Messages request. | diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index fca8620e2a..d1bb82c00e 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -22,7 +22,6 @@ import { WebError } from '@deepseek-ai/dsh-web' import type { - WebProviderStatus, WebSearchProvider, WebSearchRequest, WebSearchResult, @@ -64,7 +63,7 @@ const USER_AGENT = 'deepseek-harness/0.0.1' /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ export interface DeepSeekSearchProviderOptions { - /** DeepSeek API key. Empty/absent → `status()` reports `missing-credential`. */ + /** DeepSeek API key. Empty/absent makes the provider unavailable. */ apiKey: string /** Endpoint base; `/messages` is appended. */ baseURL: string @@ -111,11 +110,10 @@ export function citationSnippets(blocks: readonly ContentBlock[]): Map block.type === 'web_search_tool_result', @@ -143,7 +141,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. */ @@ -152,14 +150,14 @@ export class DeepSeekSearchProvider implements WebSearchProvider { constructor(private readonly options: DeepSeekSearchProviderOptions) {} - status(): WebProviderStatus { - if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } - if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' } - if (!isPositiveInteger(this.options.maxTokens) || !isPositiveInteger(this.options.maxUses)) return { available: false, reason: 'misconfigured' } - return { available: true } + available(): boolean { + return this.options.apiKey.length > 0 + && URL.canParse(this.options.baseURL) + && isPositiveInteger(this.options.maxTokens) + && isPositiveInteger(this.options.maxUses) } - async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise { + async search(request: WebSearchRequest, signal?: AbortSignal): Promise { let response: Response try { response = await fetch(`${this.options.baseURL}/messages`, { @@ -183,7 +181,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider { }], tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }], }), - ...exec?.signal ? { signal: exec.signal } : {}, + ...signal !== undefined ? { signal } : {}, }) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) @@ -211,7 +209,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider { try { const payload = await response.json() as AnthropicResponse - return mapAnthropicResponse(request.query, payload) + return mapAnthropicResponse(payload) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) if (error instanceof WebError) throw error diff --git a/packages/web/web-search-deepseek/tests/deepseek.e2e.ts b/packages/web/web-search-deepseek/tests/deepseek.e2e.ts index f828fbd12e..03c99f9d9b 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.e2e.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.e2e.ts @@ -29,7 +29,6 @@ maybe('DeepSeekSearchProvider real API', () => { maxUses: DEEPSEEK_DEFAULT_MAX_USES, }) 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) for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) }, 60_000) diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index 0faab1f35a..12ee4ba55a 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -64,10 +64,8 @@ describe('citationSnippets', () => { describe('mapAnthropicResponse', () => { 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({ - providerId: DEEPSEEK_PROVIDER_ID, - query: 'q', sources: [ { url: 'https://a.test', title: 'A', snippet: 'excerpt for A', publishedAt: '2026-02-02' }, { url: 'https://b.test', title: 'B' }, @@ -77,7 +75,7 @@ describe('mapAnthropicResponse', () => { }) it('dedupes repeated urls across result blocks (first wins)', () => { - const result = mapAnthropicResponse('q', { + const result = mapAnthropicResponse({ 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: 'second' }] }, @@ -87,7 +85,7 @@ describe('mapAnthropicResponse', () => { }) it('skips non-result items and items with an empty url', () => { - const result = mapAnthropicResponse('q', { + const result = mapAnthropicResponse({ content: [{ type: 'web_search_tool_result', content: [ @@ -101,14 +99,14 @@ describe('mapAnthropicResponse', () => { }) 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: '' }] }], }) expect(result.sources).toEqual([{ url: 'https://a.test' }]) }) it('tolerates a text block with no citations', () => { - const result = mapAnthropicResponse('q', { + const result = mapAnthropicResponse({ content: [ { type: 'text', text: 'no citations here' }, { 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', () => { - const result = mapAnthropicResponse('q', { + const result = mapAnthropicResponse({ content: [ { type: 'web_search_tool_result' }, { 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', () => { - 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' })) }) it('throws WEB_PROVIDER_ERROR when content is absent entirely', () => { - expect(() => mapAnthropicResponse('q', {})) + expect(() => mapAnthropicResponse({})) .toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) }) }) -describe('DeepSeekSearchProvider status', () => { +describe('DeepSeekSearchProvider availability', () => { it('is unavailable without a key', () => { - expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).status()) - .toEqual({ available: false, reason: 'missing-credential' }) + expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).available()).toBe(false) }) 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', () => { - expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) + expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false) }) it('is misconfigured when request limits are not positive integers', () => { - expect(new DeepSeekSearchProvider({ ...options, maxTokens: 0 }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) - expect(new DeepSeekSearchProvider({ ...options, maxUses: 0 }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) - expect(new DeepSeekSearchProvider({ ...options, maxUses: 1.5 }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) + expect(new DeepSeekSearchProvider({ ...options, maxTokens: 0 }).available()).toBe(false) + expect(new DeepSeekSearchProvider({ ...options, maxUses: 0 }).available()).toBe(false) + expect(new DeepSeekSearchProvider({ ...options, maxUses: 1.5 }).available()).toBe(false) }) }) @@ -186,7 +179,7 @@ describe('DeepSeekSearchProvider request mapping', () => { const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) vi.stubGlobal('fetch', fetchMock) 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] expect(init.signal).toBe(controller.signal) }) @@ -268,7 +261,7 @@ describe('web-search-deepseek plugin registration', () => { const ctx = new Context() await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) 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 expect(ctx.web.search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) @@ -324,7 +317,7 @@ describe('web-search-deepseek plugin registration', () => { const unwrapped = loader.unwrapExports(deepseekPlugin) as Parameters[0] // A collapsed export shape (dropped inject) would throw "without inject" here. 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() }) diff --git a/packages/web/web-search-exa/README.md b/packages/web/web-search-exa/README.md index 0bc58d6559..6981281c40 100644 --- a/packages/web/web-search-exa/README.md +++ b/packages/web/web-search-exa/README.md @@ -8,8 +8,8 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i | Key | Default | Meaning | |---|---|---| -| `apiKey` | `$EXA_API_KEY` | Exa API key. Empty/absent → provider `status()` reports `missing-credential` (the seam reports `configured-unavailable`/`none`). | -| `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. An unparseable value makes `status()` report `misconfigured`. | +| `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 the provider unavailable. | | `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. | | `highlightsPerResult` | `1` | Highlight sentences requested per result (Exa's `highlightsPerUrl`). Must be a positive integer. | diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts index 48514c07bd..618c88fe3a 100644 --- a/packages/web/web-search-exa/src/provider.ts +++ b/packages/web/web-search-exa/src/provider.ts @@ -14,7 +14,6 @@ import { WebError } from '@deepseek-ai/dsh-web' import type { - WebProviderStatus, WebSearchProvider, WebSearchRequest, WebSearchResult, @@ -39,7 +38,7 @@ const USER_AGENT = 'deepseek-harness/0.0.1' /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ export interface ExaSearchProviderOptions { - /** Exa API key. Empty/absent → `status()` reports `missing-credential`. */ + /** Exa API key. Empty/absent makes the provider unavailable. */ apiKey: string /** Endpoint base; `/search` is appended. */ baseURL: string @@ -74,18 +73,17 @@ export function mapExaResult(result: ExaResult): WebSearchSource | undefined { /** * 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. * @returns the normalized result; snippet-less entries are dropped * ({@link mapExaResult}). */ -export function mapExaResponse(query: string, response: ExaSearchResponse): WebSearchResult { +export function mapExaResponse(response: ExaSearchResponse): WebSearchResult { const sources = (response.results ?? []) .map(mapExaResult) .filter((source): source is WebSearchSource => source !== undefined) // Exa returns no generated answer, so `content` is omitted. The seam owns the // 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. */ @@ -94,15 +92,14 @@ export class ExaSearchProvider implements WebSearchProvider { constructor(private readonly options: ExaSearchProviderOptions) {} - status(): WebProviderStatus { - if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } - if (!isValidBaseUrl(this.options.baseURL)) return { available: false, reason: 'misconfigured' } - if (!isPositiveInteger(this.options.highlightsPerResult)) return { available: false, reason: 'misconfigured' } - if (this.options.numResults !== undefined && !isPositiveInteger(this.options.numResults)) return { available: false, reason: 'misconfigured' } - return { available: true } + available(): boolean { + return this.options.apiKey.length > 0 + && isValidBaseUrl(this.options.baseURL) + && isPositiveInteger(this.options.highlightsPerResult) + && (this.options.numResults === undefined || isPositiveInteger(this.options.numResults)) } - async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise { + async search(request: WebSearchRequest, signal?: AbortSignal): Promise { // A per-request bound wins over the configured default; either may be absent. const numResults = request.maxResults ?? this.options.numResults let response: Response @@ -121,7 +118,7 @@ export class ExaSearchProvider implements WebSearchProvider { contents: { highlights: { highlightsPerUrl: this.options.highlightsPerResult } }, ...numResults !== undefined ? { numResults } : {}, }), - ...exec?.signal ? { signal: exec.signal } : {}, + ...signal !== undefined ? { signal } : {}, }) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) @@ -149,7 +146,7 @@ export class ExaSearchProvider implements WebSearchProvider { try { const payload = await response.json() as ExaSearchResponse - return mapExaResponse(request.query, payload) + return mapExaResponse(payload) } catch (error: unknown) { 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 }) diff --git a/packages/web/web-search-exa/tests/exa.e2e.ts b/packages/web/web-search-exa/tests/exa.e2e.ts index c9e9233bc0..ff04371881 100644 --- a/packages/web/web-search-exa/tests/exa.e2e.ts +++ b/packages/web/web-search-exa/tests/exa.e2e.ts @@ -17,7 +17,6 @@ maybe('ExaSearchProvider real API', () => { highlightsPerResult: EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, }) const result = await provider.search({ query: 'DeepSeek Harness SDK', maxResults: 5 }) - expect(result.providerId).toBe('exa') expect(result.sources.length).toBeGreaterThan(0) for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) }, 30_000) diff --git a/packages/web/web-search-exa/tests/exa.spec.ts b/packages/web/web-search-exa/tests/exa.spec.ts index 204b18f702..cf66ea67f9 100644 --- a/packages/web/web-search-exa/tests/exa.spec.ts +++ b/packages/web/web-search-exa/tests/exa.spec.ts @@ -38,7 +38,7 @@ describe('Exa result mapping', () => { }) it('maps a response to a result with no content and filtered sources', () => { - const result = mapExaResponse('q', { + const result = mapExaResponse({ results: [ { url: 'https://a.test', highlights: ['one'] }, { url: 'https://b.test' }, @@ -46,8 +46,6 @@ describe('Exa result mapping', () => { ], }) expect(result).toEqual({ - providerId: EXA_PROVIDER_ID, - query: 'q', sources: [ { url: 'https://a.test', snippet: 'one' }, { url: 'https://c.test', title: 'C', snippet: 'three' }, @@ -58,36 +56,31 @@ describe('Exa result mapping', () => { }) 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', () => { - expect(new ExaSearchProvider({ ...options, apiKey: '' }).status()) - .toEqual({ available: false, reason: 'missing-credential' }) + expect(new ExaSearchProvider({ ...options, apiKey: '' }).available()).toBe(false) }) 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', () => { - expect(new ExaSearchProvider({ ...options, baseURL: 'not a url' }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) + expect(new ExaSearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false) }) it('is misconfigured when highlightsPerResult is not a positive integer', () => { - expect(new ExaSearchProvider({ ...options, highlightsPerResult: 0 }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) - expect(new ExaSearchProvider({ ...options, highlightsPerResult: 1.5 }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) + expect(new ExaSearchProvider({ ...options, highlightsPerResult: 0 }).available()).toBe(false) + expect(new ExaSearchProvider({ ...options, highlightsPerResult: 1.5 }).available()).toBe(false) }) it('is misconfigured when numResults is set but not a positive integer', () => { - expect(new ExaSearchProvider({ ...options, numResults: -1 }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) + expect(new ExaSearchProvider({ ...options, numResults: -1 }).available()).toBe(false) }) }) @@ -139,7 +132,7 @@ describe('ExaSearchProvider request mapping', () => { const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) vi.stubGlobal('fetch', fetchMock) 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] expect(init.signal).toBe(controller.signal) }) @@ -209,7 +202,7 @@ describe('web-search-exa plugin registration', () => { const ctx = new Context() await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID }) 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 expect(ctx.web.search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) diff --git a/packages/web/web-search-perplexity/README.md b/packages/web/web-search-perplexity/README.md index f944413c96..1a17026771 100644 --- a/packages/web/web-search-perplexity/README.md +++ b/packages/web/web-search-perplexity/README.md @@ -8,8 +8,8 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i | Key | Default | Meaning | |---|---|---| -| `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key. Empty/absent → provider `status()` reports `missing-credential`. | -| `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. An unparseable value makes `status()` report `misconfigured`. | +| `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 the provider unavailable. | | `model` | `sonar` | Search model name. | | `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. | diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index 3f1959549a..2efa735516 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/packages/web/web-search-perplexity/src/provider.ts @@ -15,7 +15,6 @@ import { WebError } from '@deepseek-ai/dsh-web' import type { - WebProviderStatus, WebSearchProvider, WebSearchRequest, WebSearchResult, @@ -43,7 +42,7 @@ const USER_AGENT = 'deepseek-harness/0.0.1' /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ export interface PerplexitySearchProviderOptions { - /** Perplexity API key. Empty/absent → `status()` reports `missing-credential`. */ + /** Perplexity API key. Empty/absent makes the provider unavailable. */ apiKey: string /** Endpoint base; `/chat/completions` is appended. */ baseURL: string @@ -75,18 +74,15 @@ export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSo * structured `search_results[]`; falls back to URL-only `citations[]` (those * 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. * @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 sources: WebSearchSource[] = response.search_results !== undefined ? response.search_results.map(mapPerplexityResult) : (response.citations ?? []).map(url => ({ url })) return { - providerId: PERPLEXITY_PROVIDER_ID, - query, ...content != null && content.length > 0 ? { content } : {}, sources, truncated: false, @@ -102,15 +98,14 @@ export class PerplexitySearchProvider implements WebSearchProvider { // Availability checks stay beside each provider's distinct config contract; // a shared base class would obscure which fields make this backend usable. /* jscpd:ignore-start */ - status(): WebProviderStatus { - if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } - if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' } - if (!isPositiveInteger(this.options.maxTokens)) return { available: false, reason: 'misconfigured' } - return { available: true } + available(): boolean { + return this.options.apiKey.length > 0 + && URL.canParse(this.options.baseURL) + && isPositiveInteger(this.options.maxTokens) } /* jscpd:ignore-end */ - async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise { + async search(request: WebSearchRequest, signal?: AbortSignal): Promise { let response: Response try { response = await fetch(`${this.options.baseURL}/chat/completions`, { @@ -127,7 +122,7 @@ export class PerplexitySearchProvider implements WebSearchProvider { messages: [{ role: 'user', content: request.query }], ...this.options.searchRecency !== undefined ? { search_recency_filter: this.options.searchRecency } : {}, }), - ...exec?.signal ? { signal: exec.signal } : {}, + ...signal !== undefined ? { signal } : {}, }) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) @@ -155,7 +150,7 @@ export class PerplexitySearchProvider implements WebSearchProvider { try { const payload = await response.json() as PerplexityResponse - return mapPerplexityResponse(request.query, payload) + return mapPerplexityResponse(payload) } catch (error: unknown) { 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 }) diff --git a/packages/web/web-search-perplexity/tests/perplexity.e2e.ts b/packages/web/web-search-perplexity/tests/perplexity.e2e.ts index 2fc89db7d4..e8f7474caa 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.e2e.ts +++ b/packages/web/web-search-perplexity/tests/perplexity.e2e.ts @@ -17,7 +17,6 @@ maybe('PerplexitySearchProvider real API', () => { maxTokens: PERPLEXITY_DEFAULT_MAX_TOKENS, }) const result = await provider.search({ query: 'What is the DeepSeek Harness SDK?', maxResults: 5 }) - expect(result.providerId).toBe('perplexity') expect(result.content ?? '').not.toBe('') for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) }, 30_000) diff --git a/packages/web/web-search-perplexity/tests/perplexity.spec.ts b/packages/web/web-search-perplexity/tests/perplexity.spec.ts index df8a98f003..e769989d73 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.spec.ts +++ b/packages/web/web-search-perplexity/tests/perplexity.spec.ts @@ -20,7 +20,7 @@ afterEach(() => { describe('Perplexity response mapping', () => { it('maps the answer and prefers structured search_results', () => { - const result = mapPerplexityResponse('q', { + const result = mapPerplexityResponse({ choices: [{ message: { content: 'the answer' } }], search_results: [ { url: 'https://a.test', title: 'A', snippet: 'snip', date: '2026-02-02' }, @@ -29,8 +29,6 @@ describe('Perplexity response mapping', () => { citations: ['https://ignored.test'], }) expect(result).toEqual({ - providerId: PERPLEXITY_PROVIDER_ID, - query: 'q', content: 'the answer', sources: [ { 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', () => { - const result = mapPerplexityResponse('q', { + const result = mapPerplexityResponse({ choices: [{ message: { content: 'answer' } }], 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', () => { - expect(mapPerplexityResponse('q', { citations: [] }).content).toBeUndefined() - expect(mapPerplexityResponse('q', { choices: [{ message: { content: '' } }] }).content).toBeUndefined() - expect(mapPerplexityResponse('q', { choices: [{ message: { content: null } }] }).content).toBeUndefined() + expect(mapPerplexityResponse({ citations: [] }).content).toBeUndefined() + expect(mapPerplexityResponse({ choices: [{ message: { content: '' } }] }).content).toBeUndefined() + expect(mapPerplexityResponse({ choices: [{ message: { content: null } }] }).content).toBeUndefined() }) 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 }], }) expect(result.sources).toEqual([{ url: 'https://a.test' }]) }) 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', () => { - expect(new PerplexitySearchProvider({ ...options, apiKey: '' }).status()) - .toEqual({ available: false, reason: 'missing-credential' }) + expect(new PerplexitySearchProvider({ ...options, apiKey: '' }).available()).toBe(false) }) 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', () => { - expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) + expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false) }) it('is misconfigured when maxTokens is not a positive integer', () => { - expect(new PerplexitySearchProvider({ ...options, maxTokens: 0 }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) - expect(new PerplexitySearchProvider({ ...options, maxTokens: 1.5 }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) + expect(new PerplexitySearchProvider({ ...options, maxTokens: 0 }).available()).toBe(false) + expect(new PerplexitySearchProvider({ ...options, maxTokens: 1.5 }).available()).toBe(false) }) }) @@ -114,7 +108,7 @@ describe('PerplexitySearchProvider request mapping', () => { const fetchMock = vi.fn(async () => jsonResponse({ citations: [] })) vi.stubGlobal('fetch', fetchMock) 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] expect(init.signal).toBe(controller.signal) }) @@ -190,7 +184,7 @@ describe('web-search-perplexity plugin registration', () => { const ctx = new Context() await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID }) 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 expect(ctx.web.search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) diff --git a/packages/web/web/README.md b/packages/web/web/README.md index fe5f1e650e..ce664ee6d8 100644 --- a/packages/web/web/README.md +++ b/packages/web/web/README.md @@ -19,8 +19,8 @@ Search and fetch share no request schema and no business logic, but they are del | 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. | -| `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. | -| `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. | +| `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, 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. @@ -30,15 +30,15 @@ Selection never depends on registration, config, or HMR order. A capability has | 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 registered but unavailable | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` | | no id, exactly one registered usable provider | runs it | | no id, no usable provider | `WEB_PROVIDER_UNAVAILABLE` | | 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 -`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. diff --git a/packages/web/web/src/index.ts b/packages/web/web/src/index.ts index 5da736a85a..39620777b5 100644 --- a/packages/web/web/src/index.ts +++ b/packages/web/web/src/index.ts @@ -18,11 +18,9 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import type { - WebExecContext, WebFetchProvider, WebFetchRequest, WebFetchResult, - WebProviderStatus, WebSearchProvider, WebSearchRequest, WebSearchResult, @@ -33,12 +31,10 @@ export { WebError, } from './types.ts' export type { - WebExecContext, WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult, - WebProviderStatus, WebSearchProvider, WebSearchRequest, WebSearchResult, @@ -76,7 +72,7 @@ export interface WebServiceConfig { * The web access service. Registered as `ctx.web` (one instance per context). * * 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 registered but unavailable → * `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. @@ -147,15 +143,15 @@ export class WebService extends Service { * capability cannot run. The seam enforces `request.maxResults` on the result: * if the provider over-returns, `sources[]` is truncated and `truncated` set. * @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`. */ - async search(request: WebSearchRequest, exec?: WebExecContext): Promise { + async search(request: WebSearchRequest, signal?: AbortSignal): Promise { const provider = resolveProvider({ providers: this.searchProviders, ...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) } @@ -164,21 +160,21 @@ export class WebService extends Service { * 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. * @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. */ - async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise { + async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise { const provider = resolveProvider({ providers: this.fetchProviders, ...this.fetchProviderId !== undefined ? { configuredId: this.fetchProviderId } : {}, }) - return provider.fetch(request, exec) + return provider.fetch(request, signal) } } interface ResolvableProvider { readonly id: string - status(): WebProviderStatus + available(): boolean } /** Resolve the selected provider or throw the matching {@link WebError}. */ @@ -189,12 +185,12 @@ function resolveProvider

(selection: Selection

): if (!provider) { 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') } return provider } - const usable = [...providers.values()].filter(provider => provider.status().available) + const usable = [...providers.values()].filter(provider => provider.available()) const [single] = usable if (single === undefined) { throw new WebError('no usable web provider is registered', 'WEB_PROVIDER_UNAVAILABLE') diff --git a/packages/web/web/src/types.ts b/packages/web/web/src/types.ts index f4cda691b8..d16855aa69 100644 --- a/packages/web/web/src/types.ts +++ b/packages/web/web/src/types.ts @@ -1,8 +1,7 @@ /** * Vocabulary for the web capability seam (`ctx.web`): the search/fetch - * request/result shapes providers produce and consumers format, the provider - * status discriminant selection reads, the execution-control context, and the - * typed error taxonomy. + * request/result shapes providers produce and consumers format, provider + * availability, direct cancellation control, and the typed error taxonomy. * * These types are shared by every provider backend * (`@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`, @@ -19,19 +18,6 @@ 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 * a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged @@ -56,10 +42,6 @@ export interface WebSearchRequest { * when it cut `sources[]` down to `maxResults`. */ 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. */ readonly content?: string /** Citeable sources, already truncated to the request's `maxResults`. */ @@ -83,14 +65,13 @@ export interface WebSearchSource { } /** - * What one fetch-capable backend is asked to retrieve. `timeoutMs` is an - * optional positive hint the provider caps. The request deliberately omits - * `format`, `prompt`, and extraction controls — those are presentation or - * higher-level LLM concerns, not safe-retrieval inputs. + * What one fetch-capable backend is asked to retrieve. The request deliberately + * omits timeout, format, prompt, and extraction controls: cancellation is a + * direct execution argument, while presentation and higher-level LLM concerns + * belong outside safe retrieval. */ export interface WebFetchRequest { readonly url: string - readonly timeoutMs?: number } /** @@ -100,8 +81,6 @@ export interface WebFetchRequest { * represent the resource. */ 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). */ readonly url: string /** HTTP status code of the fetched response. */ @@ -125,18 +104,6 @@ export type WebFetchBody = | { readonly kind: 'html'; 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`. * `id` is a stable string, unique within the search capability kind. @@ -144,9 +111,9 @@ export type WebProviderStatus = export interface WebSearchProvider { readonly id: string /** Cheap local usability check; must not make network calls. */ - status(): WebProviderStatus - /** Run one search; honor `exec.signal` for cancellation. */ - search(request: WebSearchRequest, exec?: WebExecContext): Promise + available(): boolean + /** Run one search; honor `signal` for cancellation. */ + search(request: WebSearchRequest, signal?: AbortSignal): Promise } /** @@ -156,9 +123,9 @@ export interface WebSearchProvider { export interface WebFetchProvider { readonly id: string /** Cheap local usability check; must not make network calls. */ - status(): WebProviderStatus - /** Retrieve one URL; honor `exec.signal` for cancellation. */ - fetch(request: WebFetchRequest, exec?: WebExecContext): Promise + available(): boolean + /** Retrieve one URL; honor `signal` for cancellation. */ + fetch(request: WebFetchRequest, signal?: AbortSignal): Promise } /** @@ -178,12 +145,12 @@ export interface WebFetchProvider { * - `WEB_PROVIDER_UNAVAILABLE`: no provider configured and none usable. * - `WEB_PROVIDER_CONFIGURED_MISSING`: a configured id is not registered. * - `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`: a configured id is registered but its - * `status()` reports unavailable. + * `available()` returns false. * - `WEB_PROVIDER_AMBIGUOUS`: no id configured and multiple usable providers * exist (selection refuses to pick by registration order). * - `WEB_DUPLICATE_PROVIDER`: a registration-time programming error — an id is * already registered for that capability kind. - * - `WEB_ABORTED`: the operation was aborted via `WebExecContext.signal`. + * - `WEB_ABORTED`: the operation was aborted via its optional signal. * - `WEB_PROVIDER_ERROR`: catch-all for a provider's own failure surfaced * through the seam, including network/transport failure (DNS, connection * refused, TLS). diff --git a/packages/web/web/tests/web.spec.ts b/packages/web/web/tests/web.spec.ts index 8189e342da..978284ee51 100644 --- a/packages/web/web/tests/web.spec.ts +++ b/packages/web/web/tests/web.spec.ts @@ -4,7 +4,6 @@ import WebService, { WebError, type WebFetchProvider, type WebFetchResult, - type WebProviderStatus, type WebSearchProvider, type WebSearchRequest, type WebSearchResult, @@ -13,25 +12,25 @@ import WebService, { /** A scripted search provider for contract tests. */ function makeSearchProvider( id: string, - status: WebProviderStatus, + available: boolean, search: (request: WebSearchRequest) => Promise, ): 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 { - return { id, status: () => status, fetch: () => Promise.resolve(result) } +function makeFetchProvider(id: string, available: boolean, result: WebFetchResult): WebFetchProvider { + return { id, available: () => available, fetch: () => Promise.resolve(result) } } -const available: WebProviderStatus = { available: true } -const unavailable: WebProviderStatus = { available: false, reason: 'missing-credential' } +const available = true +const unavailable = false -function searchResult(providerId: string, overrides: Partial = {}): WebSearchResult { - return { providerId, query: 'q', sources: [], truncated: false, ...overrides } +function searchResult(marker: string, overrides: Partial = {}): WebSearchResult { + return { content: marker, sources: [], truncated: false, ...overrides } } -function fetchResult(providerId: string): WebFetchResult { - return { providerId, url: 'https://example.com', statusCode: 200, body: { kind: 'text', content: 'hi' }, truncated: false } +function fetchResult(marker: string): WebFetchResult { + 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. */ @@ -46,7 +45,7 @@ describe('WebService registration', () => { const { web } = await mountWeb() 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() 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) => { inner.web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) }, { 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 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' }) web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) 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 () => { const { web } = await mountWeb() web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) 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 () => { const a = await mountWeb() a.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) 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() b.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) 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 () => { @@ -139,7 +138,6 @@ describe('WebService execution resolution', () => { searchResult('exa', { content: 'answer', sources: [{ url: 'https://a' }] }), ))) const result = await web.search({ query: 'q' }) - expect(result.providerId).toBe('exa') expect(result.content).toBe('answer') expect(result.sources).toEqual([{ url: 'https://a' }]) }) @@ -149,11 +147,11 @@ describe('WebService execution resolution', () => { const seen: (AbortSignal | undefined)[] = [] web.registerSearchProvider({ id: 'exa', - status: () => available, - search: (_request, exec) => { seen.push(exec?.signal); return Promise.resolve(searchResult('exa')) }, + available: () => available, + search: (_request, signal) => { seen.push(signal); return Promise.resolve(searchResult('exa')) }, }) 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) }) }) @@ -195,7 +193,7 @@ describe('WebService fetch capability', () => { const { web } = await mountWeb() web.registerFetchProvider(makeFetchProvider('local-http', available, fetchResult('local-http'))) 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) }) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index a219ea1b1d..c35d5340f7 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -133,7 +133,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": "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": "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": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" }, From a8d1624695b78677e65d287b87aea016f3de1288 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 04:26:35 +0800 Subject: [PATCH 05/13] docs: close web seam simplification RFC --- docs/rfc/INDEX.md | 2 +- .../2026-06-24-web-capability-seam.md | 2 +- .../2026-07-12-prune-unused-web-seam-fields.md | 17 ++++++----------- 3 files changed, 8 insertions(+), 13 deletions(-) rename docs/rfc/{proposed => implemented}/simplification/2026-07-12-prune-unused-web-seam-fields.md (61%) diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index b338edfc52..97f0f5c511 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -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 | | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | | [Drop unconsumed skill provider events](proposed/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 | -| [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 | ### Architecture @@ -100,6 +99,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Share the app bins' boot glue instead of maintaining twin copies](implemented/simplification/2026-07-04-share-app-bin-boot-glue.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 | +| [Prune unused web seam fields](implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 | ### Architecture diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md index 0763a771f5..3d907bf334 100644 --- a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md @@ -64,7 +64,7 @@ flowchart LR 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 on `@deepseek-ai/dsh-web` and Cordis. They own credentials, endpoint config, provider-specific request mapping, provider-specific response parsing, and provider-specific error translation into `WebError`. They issue network requests with platform-native `fetch` at the repo's Node floor, mirroring `@deepseek-ai/dsh-llm-deepseek`'s adapter, NOT a cordis HTTP-client service (`ctx.http`/`@cordisjs/plugin-http`) — even where a Perplexity provider's request is shaped like an OpenAI-compatible chat completion, that wire shape is a provider-private detail and does not make the provider depend on `ctx.llm`. A provider does NOT own the `ctx.web` key (two search providers cannot both own it): like `dsh-llm-deepseek`, each provider package is a function/namespace plugin (`inject: ['web']`) whose `apply` constructs the backend and calls `ctx.web.registerSearchProvider` / `registerFetchProvider`. `@deepseek-ai/dsh-web` is the `export default` service that owns the key. diff --git a/docs/rfc/proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md similarity index 61% rename from docs/rfc/proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md rename to docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md index 1a8495426e..8ece4214b7 100644 --- a/docs/rfc/proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md +++ b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md @@ -1,6 +1,6 @@ # RFC: Prune unused web seam fields -Status: proposed +Status: implemented ## 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. -## 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 **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. -- 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 +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. 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. From 0815ff4db4857447de9da581a26cd0d8a61c160d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:00:54 +0800 Subject: [PATCH 06/13] refactor: share loader smoke harness --- docs/config-catalog.md | 1 + docs/module-graph.md | 2 + examples/AGENTS.md | 2 +- .../tests/code-mode-keyless-smoke.e2e.ts | 102 ++------------ .../coding-agent/tests/keyless-smoke.e2e.ts | 116 +++------------- .../cordis-agent/tests/keyless-smoke.e2e.ts | 105 ++------------ examples/echo-agent/tests/echo.e2e.ts | 130 +++--------------- knip.json | 5 + packages/README.md | 2 +- packages/support/README.md | 3 +- packages/support/loader-smoke/README.md | 7 + packages/support/loader-smoke/package.json | 33 +++++ packages/support/loader-smoke/src/index.ts | 117 ++++++++++++++++ .../loader-smoke/tests/fixtures/fail.ts | 4 + .../loader-smoke/tests/fixtures/hang.ts | 4 + .../loader-smoke/tests/fixtures/success.ts | 16 +++ .../loader-smoke/tests/loader-smoke.spec.ts | 61 ++++++++ packages/support/loader-smoke/tsconfig.json | 11 ++ pnpm-lock.yaml | 10 ++ tsconfig.build.json | 1 + tsconfig.json | 1 + 21 files changed, 344 insertions(+), 389 deletions(-) create mode 100644 packages/support/loader-smoke/README.md create mode 100644 packages/support/loader-smoke/package.json create mode 100644 packages/support/loader-smoke/src/index.ts create mode 100644 packages/support/loader-smoke/tests/fixtures/fail.ts create mode 100644 packages/support/loader-smoke/tests/fixtures/hang.ts create mode 100644 packages/support/loader-smoke/tests/fixtures/success.ts create mode 100644 packages/support/loader-smoke/tests/loader-smoke.spec.ts create mode 100644 packages/support/loader-smoke/tsconfig.json diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9d4b8038b5..cba82ef962 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1205,6 +1205,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-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-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-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)) diff --git a/docs/module-graph.md b/docs/module-graph.md index e4d0b3367d..9a0ac4cbde 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -86,6 +86,7 @@ flowchart TD pkg_acp_snapshot["acp-snapshot"] pkg_invariants["invariants"] pkg_llm_replay["llm-replay"] + pkg_loader_smoke["loader-smoke"] pkg_subagent_mock["subagent-mock"] end subgraph group_ui["packages/ui"] @@ -329,6 +330,7 @@ flowchart TD | [`skill`](../packages/skill/skill) | `skill` | — | | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — | +| [`loader-smoke`](../packages/support/loader-smoke) | `support` | — | | [`app-boot`](../packages/ui/app-boot) | `ui` | — | | [`jsonrpc-agent`](../packages/ui/jsonrpc-agent) | `ui` | — | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | diff --git a/examples/AGENTS.md b/examples/AGENTS.md index d54104a189..cfc1b07604 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -13,7 +13,7 @@ Each example must have **both** kinds of end-to-end smoke, because they catch di **Exception — keyless-by-nature examples.** An example whose model is itself a mock/deterministic stand-in (no real provider) has no meaningful with-key smoke; the keyless smoke is the complete requirement. State the exception inline in the test. -A keyless smoke that spawns the example from a temp cwd must set `TSX_TSCONFIG_PATH` to the repo-root tsconfig (the unbuilt `paths` map is found by searching UP from cwd), and pass `--expose-internals` when the `cordis.yml` loads the HMR plugin (mirror the `demo:*` script). +A keyless stdio smoke uses `@deepseek-ai/dsh-loader-smoke`, which owns the isolated cwd and DSH homes, repo tsconfig pin, `--expose-internals`, subprocess deadline, EOF, captured diagnostics, forced kill, and cleanup. The example test supplies only its absolute bin/config/tsconfig paths, environment overrides, stdin lines, and output assertions. ## Current state diff --git a/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts b/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts index 718aa96721..ac2cb9430b 100644 --- a/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts @@ -1,99 +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 { 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 `@deepseek-ai/dsh-stdio-agent` bin against - * `code-mode.cordis.yml` (the cordis Loader, `unwrapExports`, the include - * patches over ./cordis.yml, the worker-thread code runtime, and the - * registry in `mode: code`), then close stdin with no prompt and assert - * the Code Mode banner + a clean exit. - * - * No prompt is ever sent, so the model is NEVER called and no `run_code` - * turn happens — a dummy key lets `llm-deepseek`'s key-PRESENT check boot - * the tree. This is the export-shape guard (postmortem 0001) for the Code - * Mode composition; the with-key proof lives in `code-mode.e2e.ts`. + * Keyless Loader-path smoke for the Code Mode overlay: boot the real include + * tree through stdio-agent and `code-mode.cordis.yml`, then close stdin without + * a prompt and assert the banner. No model or `run_code` turn runs. */ 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 tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -// 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)) -// The real-API workflow runs up to 14 e2e files at once. Cold tsx/Loader -// startup can therefore outlive a tight smoke-test deadline before the child -// emits any output; 30s still detects a wedged process without confusing -// bounded CI contention with a lifecycle failure. -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() - }) -} +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) 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 () => { - const { stdout, code } = await bootAndEof() - expect(code).toBe(0) + const { stdout } = await runLoaderSmoke({ + 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.') - }, TEST_TIMEOUT_MS) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/coding-agent/tests/keyless-smoke.e2e.ts b/examples/coding-agent/tests/keyless-smoke.e2e.ts index ea223bbad4..6cfeca646e 100644 --- a/examples/coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/keyless-smoke.e2e.ts @@ -1,112 +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 { 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/coding-agent: boot the REAL example - * through the `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` (the - * cordis Loader, `unwrapExports`, the full plugin tree incl. the - * `@deepseek-ai/dsh-agent-core` bundle and the app's in-package readline UI - * module), then close stdin with no prompt and assert the - * ready banner + a clean exit. - * - * No prompt is ever sent, so the model is NEVER called — this is why it runs - * without a real key. coding-agent's `cordis.yml` loads `llm-deepseek`, whose - * `apply()` only requires a key to be PRESENT (it does not validate it and only - * uses it when a stream actually starts), so a dummy key lets the tree boot - * while the absence of any prompt guarantees no network call. The value is the - * real-Loader-path guard that the composed tree boots (see postmortem 0001; - * the app carries no `inject`, so its export SHAPE is pinned by the stdio-agent - * unit suite's unwrap assertion, not by a crash here), - * complementing coding-agent's with-key e2e suites which prove the real - * product. + * Keyless Loader-path smoke for examples/coding-agent: boot the real example + * through the stdio-agent bin and its `cordis.yml`, then close stdin without a + * prompt and assert the banner. The dummy key satisfies adapter construction; + * immediate EOF guarantees there is no model call. */ -// TODO(loader-smoke-harness): extract the shared spawn/tempdir/timeout/EOF -// harness used here, code-mode-keyless-smoke, and cordis-agent's keyless smoke. -// The dsh-stdio-agent bin (the demo:repl entry) and this example's cordis.yml. -// The bin resolves its config-path arg from CWD; the test spawns from a temp -// cwd, so we pass the example config's ABSOLUTE path. 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 tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -// 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 four levels up). -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -// The real-API workflow runs up to 14 e2e files at once. Cold tsx/Loader -// startup can therefore outlive a tight smoke-test deadline before the child -// emits any output; 30s still detects a wedged process without confusing -// bounded CI contention with a lifecycle failure. -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(), '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() - }) -} +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) 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 () => { - const { stdout, code } = await bootAndEof() - expect(code).toBe(0) + const { stdout } = await runLoaderSmoke({ + label: 'coding-agent', + tempDirPrefix: 'coding-smoke-', + binScript, + configPath, + tsconfigPath, + env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' }, + }) expect(stdout).toContain('agent REPL ready.') - }, TEST_TIMEOUT_MS) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/cordis-agent/tests/keyless-smoke.e2e.ts b/examples/cordis-agent/tests/keyless-smoke.e2e.ts index d09cdf4728..c1f20f1987 100644 --- a/examples/cordis-agent/tests/keyless-smoke.e2e.ts +++ b/examples/cordis-agent/tests/keyless-smoke.e2e.ts @@ -1,102 +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 { 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 `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` — - * the cordis Loader, `unwrapExports`, the full plugin tree INCLUDING the - * `@deepseek-ai/dsh-tool-cordis` package 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. - * - * No prompt is ever sent, so the model is NEVER called — that is why it runs - * without a real key: `llm-deepseek`'s apply() only requires a key to be - * PRESENT, and the absence of any prompt guarantees no network call. The - * with-key product proof lives in cordis-tools.e2e.ts. + * Keyless Loader-path smoke for examples/cordis-agent: boot the real tree, + * including tool-cordis resolved by package name, then close stdin without a + * prompt and assert the banner. The dummy key never reaches a model call. */ -// The dsh-stdio-agent bin (the demo:cordis entry) and this example's cordis.yml. -// The bin resolves its config-path arg from CWD; the test spawns from a temp -// cwd, so we pass the example config's ABSOLUTE path. 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 tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -// 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)) -// The real-API workflow runs up to 14 e2e files at once. Cold tsx/Loader -// startup can therefore outlive a tight smoke-test deadline before the child -// emits any output; 30s still detects a wedged process without confusing -// bounded CI contention with a lifecycle failure. -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() - }) -} +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) 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 () => { - const { stdout, code } = await bootAndEof() - expect(code).toBe(0) + const { stdout } = await runLoaderSmoke({ + label: 'cordis-agent', + tempDirPrefix: 'cordis-smoke-', + binScript, + configPath, + tsconfigPath, + env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' }, + }) expect(stdout).toContain('cordis-agent ready.') - }, TEST_TIMEOUT_MS) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/echo-agent/tests/echo.e2e.ts b/examples/echo-agent/tests/echo.e2e.ts index 5db02fb6be..1bd2733d94 100644 --- a/examples/echo-agent/tests/echo.e2e.ts +++ b/examples/echo-agent/tests/echo.e2e.ts @@ -1,131 +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 { 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 `@deepseek-ai/dsh-stdio-agent` bin against this example's - * `cordis.yml` (the cordis Loader, `unwrapExports`, the whole plugin tree), - * pipe a script of stdin lines, and assert the rendered stdout. - * - * This is the guard the per-file unit suite structurally cannot be: it drives - * the `@deepseek-ai/dsh-stdio-agent` app plugin, the `@deepseek-ai/dsh-agent-core` - * bundle it loads, the app's in-package readline UI module, AND the - * example-local `mock-llm.ts` / `echo-tool.ts` through their REAL load path - * (see docs/postmortem/0001). The app itself carries no `inject`, so a stray - * `export default` would boot rather than crash here — the export SHAPE is - * pinned by the explicit unwrap assertion in the stdio-agent unit suite; this - * smoke proves the composed tree actually runs. It needs no API key — the - * `mock-echo` adapter never touches the network — so it runs in the default e2e - * gate. - * - * Both branches of mock-llm.ts are exercised: an `echo …` line (the tool - * round-trip → `ECHO: …`) and a plain line (the direct canned reply). + * Keyless-by-nature Loader-path coverage for examples/echo-agent. The real + * tree uses its deterministic mock model, so this suite is both the boot smoke + * and the complete behavior proof for the example. */ -// The dsh-stdio-agent bin (the demo:echo entry) and this example's cordis.yml. -// The bin resolves its config-path arg from CWD; the test spawns from a temp -// cwd, so we pass the example config's ABSOLUTE path. 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 tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -// Dev/test run UNBUILT: `@deepseek-ai/dsh-*` imports resolve through the root -// tsconfig `paths` map, which tsx finds by searching UP from cwd. We spawn from -// a temp cwd OUTSIDE the repo, so point tsx at the repo tsconfig explicitly -// (repo root is four levels up from examples/echo-agent/tests). -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -// The real-API workflow runs up to 14 e2e files at once. Cold tsx/Loader -// startup can therefore outlive a tight smoke-test deadline before the child -// emits any output; 30s still detects a wedged process without confusing -// bounded CI contention with a lifecycle failure. -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 +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -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 -}) - -/** - * 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). The whole point is to boot - // the example EXACTLY as it really runs, through the bin + Loader. - ['--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() +async function runEcho(stdinLines: readonly string[]): Promise { + const { stdout } = await runLoaderSmoke({ + label: 'echo-agent', + tempDirPrefix: 'echo-smoke-', + binScript, + configPath, + tsconfigPath, + stdinLines, }) + return stdout } describe('echo-agent keyless smoke (real cordis.yml via the Loader)', () => { it('boots, prints its welcome banner, and exits cleanly on stdin EOF', async () => { - const { stdout, code } = await runEcho([]) - expect(code).toBe(0) - expect(stdout).toContain('echo-agent ready.') - }, TEST_TIMEOUT_MS) + expect(await runEcho([])).toContain('echo-agent ready.') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('runs the echo tool round-trip for an "echo …" line', async () => { - const { stdout } = await runEcho(['echo hello world']) - // mock-llm.ts emits a tool-call for the echo tool; echo-tool.ts uppercases. + const stdout = await runEcho(['echo hello world']) expect(stdout).toContain('[tool call] echo') 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 () => { - const { stdout } = await runEcho(['just chatting']) - // The direct-response branch of mock-llm.ts quotes the input back. + const stdout = await runEcho(['just chatting']) expect(stdout).toContain('just chatting') expect(stdout).not.toContain('[tool call]') - }, TEST_TIMEOUT_MS) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/knip.json b/knip.json index 825980f205..77f5c05f2d 100644 --- a/knip.json +++ b/knip.json @@ -41,6 +41,11 @@ "project": ["src/**/*.ts", "tests/**/*.ts"], "ignoreDependencies": ["cordis"] }, + "packages/support/loader-smoke": { + "entry": ["tests/**/*.spec.ts", "tests/fixtures/*.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"], + "ignoreDependencies": ["cordis"] + }, "packages/core/agent-loop": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/README.md b/packages/README.md index 7b6b1bb32a..f060b59163 100644 --- a/packages/README.md +++ b/packages/README.md @@ -26,7 +26,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`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) | Dev/test/example infrastructure (invariants, replay, Loader-smoke, and subagent test helpers) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded` primitive) | Support — small, stable, harness-dep-free | 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). diff --git a/packages/support/README.md b/packages/support/README.md index c8883a89ff..32ca292e4e 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -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) | | `invariants/` | Dev-mode event-contract assertions | (listens on `session/*`, `agent/*`) | +| `loader-smoke/` | Shared real-Loader subprocess harness for keyless example smokes | (library — imported by example e2e suites) | | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | | `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) | -`invariants` runs only in dev mode (contract checks, not runtime behavior). `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` runs only in dev mode (contract checks, not runtime behavior). `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. diff --git a/packages/support/loader-smoke/README.md b/packages/support/loader-smoke/README.md new file mode 100644 index 0000000000..4901924a73 --- /dev/null +++ b/packages/support/loader-smoke/README.md @@ -0,0 +1,7 @@ +# `@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}`. diff --git a/packages/support/loader-smoke/package.json b/packages/support/loader-smoke/package.json new file mode 100644 index 0000000000..ddba421b41 --- /dev/null +++ b/packages/support/loader-smoke/package.json @@ -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" + } +} diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts new file mode 100644 index 0000000000..72839c6a05 --- /dev/null +++ b/packages/support/loader-smoke/src/index.ts @@ -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 + /** 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 { + 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 }) + } +} diff --git a/packages/support/loader-smoke/tests/fixtures/fail.ts b/packages/support/loader-smoke/tests/fixtures/fail.ts new file mode 100644 index 0000000000..98d2b44fb8 --- /dev/null +++ b/packages/support/loader-smoke/tests/fixtures/fail.ts @@ -0,0 +1,4 @@ +/** Non-zero subprocess fixture for the Loader-smoke harness. */ + +console.error('fixture failed') +process.exitCode = 7 diff --git a/packages/support/loader-smoke/tests/fixtures/hang.ts b/packages/support/loader-smoke/tests/fixtures/hang.ts new file mode 100644 index 0000000000..97b68153ff --- /dev/null +++ b/packages/support/loader-smoke/tests/fixtures/hang.ts @@ -0,0 +1,4 @@ +/** Deadline subprocess fixture for the Loader-smoke harness. */ + +console.log('fixture hanging') +setInterval(() => {}, 1_000) diff --git a/packages/support/loader-smoke/tests/fixtures/success.ts b/packages/support/loader-smoke/tests/fixtures/success.ts new file mode 100644 index 0000000000..fed57162e2 --- /dev/null +++ b/packages/support/loader-smoke/tests/fixtures/success.ts @@ -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') +}) diff --git a/packages/support/loader-smoke/tests/loader-smoke.spec.ts b/packages/support/loader-smoke/tests/loader-smoke.spec.ts new file mode 100644 index 0000000000..4cc9f878f9 --- /dev/null +++ b/packages/support/loader-smoke/tests/loader-smoke.spec.ts @@ -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.') + }) +}) diff --git a/packages/support/loader-smoke/tsconfig.json b/packages/support/loader-smoke/tsconfig.json new file mode 100644 index 0000000000..749cb0208e --- /dev/null +++ b/packages/support/loader-smoke/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d02b4a9ca6..3498437639 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1070,6 +1070,16 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + 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.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/support/subagent-mock: dependencies: schemastery: diff --git a/tsconfig.build.json b/tsconfig.build.json index b3d904aa9b..9cd88de4f2 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -61,6 +61,7 @@ { "path": "./packages/ui/stdio-agent" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" }, + { "path": "./packages/support/loader-smoke" }, { "path": "./packages/subagent/subagent" }, { "path": "./packages/support/subagent-mock" }, { "path": "./packages/subagent/tool-subagent" }, diff --git a/tsconfig.json b/tsconfig.json index a52f21a86e..0512f823cc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -72,6 +72,7 @@ { "path": "./packages/ui/stdio-agent" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" }, + { "path": "./packages/support/loader-smoke" }, { "path": "./packages/subagent/subagent" }, { "path": "./packages/support/subagent-mock" }, { "path": "./packages/subagent/tool-subagent" }, From 3f676efbd96dfb7954181fd59884690cb668df1c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:06:01 +0800 Subject: [PATCH 07/13] fix: bound local fetch timer config --- docs/config-catalog.md | 4 ++-- packages/web/web-fetch-local/README.md | 2 +- packages/web/web-fetch-local/src/index.ts | 14 ++++++++++++-- .../web/web-fetch-local/tests/fetch-local.spec.ts | 7 +++++++ 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8dec62dce6..f7bb435215 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1059,7 +1059,7 @@ export interface Config { maxResponseBytes?: number /** Maximum decoded body length in characters. */ maxBodyChars?: number - /** Default fetch timeout in milliseconds. */ + /** Default fetch timeout in milliseconds, within Node's timer range. */ timeoutMs?: number /** Maximum number of same-origin redirect hops to follow. */ maxRedirects?: number @@ -1068,7 +1068,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` diff --git a/packages/web/web-fetch-local/README.md b/packages/web/web-fetch-local/README.md index e84ca775f0..f8120fe293 100644 --- a/packages/web/web-fetch-local/README.md +++ b/packages/web/web-fetch-local/README.md @@ -26,7 +26,7 @@ The provider's configured `timeoutMs` is a **resource backstop** for direct `ctx | `maxUrlLength` | `2048` | Maximum accepted request URL length. | | `maxResponseBytes` | `5_000_000` | Maximum response body size in bytes. | | `maxBodyChars` | `100_000` | Maximum decoded body length in characters. | -| `timeoutMs` | `30_000` | 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`). | | `maxRedirects` | `5` | Maximum same-origin redirect hops (`0` follows none). | | `userAgent` | `deepseek-harness/…` | `User-Agent` header. | diff --git a/packages/web/web-fetch-local/src/index.ts b/packages/web/web-fetch-local/src/index.ts index 713de4c88a..471c61af2d 100644 --- a/packages/web/web-fetch-local/src/index.ts +++ b/packages/web/web-fetch-local/src/index.ts @@ -13,6 +13,8 @@ import type {} from '@deepseek-ai/dsh-web' import { LocalFetchProvider } from './provider.ts' import type { LocalFetchLimits } from './provider.ts' +const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647 + export { LOCAL_FETCH_PROVIDER_ID, LocalFetchProvider, @@ -38,7 +40,7 @@ export interface Config { maxResponseBytes?: number /** Maximum decoded body length in characters. */ maxBodyChars?: number - /** Default fetch timeout in milliseconds. */ + /** Default fetch timeout in milliseconds, within Node's timer range. */ timeoutMs?: number /** Maximum number of same-origin redirect hops to follow. */ maxRedirects?: number @@ -65,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). */ function assertNonNegativeInteger(name: string, value: number): void { if (!Number.isInteger(value) || value < 0) { @@ -79,7 +89,7 @@ export function apply(ctx: Context, config: Config): void { assertPositiveFinite('maxUrlLength', resolved.maxUrlLength) assertPositiveFinite('maxResponseBytes', resolved.maxResponseBytes) assertPositiveFinite('maxBodyChars', resolved.maxBodyChars) - assertPositiveFinite('timeoutMs', resolved.timeoutMs) + assertTimeoutMs(resolved.timeoutMs) assertNonNegativeInteger('maxRedirects', resolved.maxRedirects) const limits: LocalFetchLimits = { maxUrlLength: resolved.maxUrlLength, diff --git a/packages/web/web-fetch-local/tests/fetch-local.spec.ts b/packages/web/web-fetch-local/tests/fetch-local.spec.ts index e3eb7d30c7..3158111134 100644 --- a/packages/web/web-fetch-local/tests/fetch-local.spec.ts +++ b/packages/web/web-fetch-local/tests/fetch-local.spec.ts @@ -396,6 +396,13 @@ describe('web-fetch-local plugin registration', () => { .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 () => { const ctx = new Context() await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) From 458f87ea03432d48a0b096b2f43c8903e8db262d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:48:19 +0800 Subject: [PATCH 08/13] refactor: remove unused surface invalidation --- packages/core/session/src/surface.ts | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 3e4ce6d89c..263322eccc 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -196,31 +196,18 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult export class SurfaceManager { /** Incremental state shared with the complete surface fold. */ private _state = createFoldState() - /** The last processed seq. -1 forces a full rebuild on first access. */ + /** The last processed seq. -1 forces the initial full fold. */ private _lastProcessedSeq = -1 constructor(private log: readonly SessionEvent[]) {} /** - * Reset to unprocessed state. Call after the log has been replaced - * wholesale (e.g. after Session seed). Not needed for normal appends — - * those are picked up incrementally. - */ - invalidate(): void { - this._lastProcessedSeq = -1 - // A wholesale rebuild is a rewrite: bump the generation so incremental - // consumers (the session's derived-message cache) discard their view. - this._state = createFoldState(this._state.replaceGeneration + 1) - } - - /** - * The surface's rewrite generation: bumped by every folded `replace` op and - * by {@link invalidate}. A replace is the ONE operation that rewrites the - * surface non-monotonically, so an incremental consumer of {@link nodes} - * (the session's derived-message cache) compares this between visits — an - * unchanged generation guarantees every node it has not seen is a pure tail - * append; a changed one means its view must rebuild. Monotonic: it never - * moves backwards, so comparisons cannot be fooled by a re-fold. + * The surface's rewrite generation, bumped by every folded `replace` op. A + * replace is the ONE operation that rewrites the surface non-monotonically, + * so an incremental consumer of {@link nodes} (the session's derived-message + * cache) compares this between visits — an unchanged generation guarantees + * every unseen node is a pure tail append; a changed one means its view must + * rebuild. */ get replaceGeneration(): number { if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() From 4cea4979c63c2b9c4b43f01372303e0ea3dc318c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:24:21 +0800 Subject: [PATCH 09/13] docs: classify loader smoke support surface --- packages/README.md | 2 +- packages/support/loader-smoke/README.md | 10 ++++++++++ scripts/verify-package-readme-model-experience.ts | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/README.md b/packages/README.md index 9f66bbc5c1..aa3120f89b 100644 --- a/packages/README.md +++ b/packages/README.md @@ -27,7 +27,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`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 | | [`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, Loader-smoke, and subagent test helpers) | 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` 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). diff --git a/packages/support/loader-smoke/README.md b/packages/support/loader-smoke/README.md index 4901924a73..ea197b25d0 100644 --- a/packages/support/loader-smoke/README.md +++ b/packages/support/loader-smoke/README.md @@ -5,3 +5,13 @@ Shared subprocess harness for keyless example smokes that boot the real stdio-ag 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. diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index de265b23ad..2ce925cbc3 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -61,6 +61,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { '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/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/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.' }, From 80fbeefbd6254acde77d8855e12b4e0442e59e94 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:02:11 +0800 Subject: [PATCH 10/13] refactor(ui): extract stdio plugin package Move the readline front door from stdio-agent into @deepseek-ai/dsh-stdio, keeping the loader shape and the stdio coverage with the new package. --- docs/config-catalog.md | 16 ++++++ docs/event-producer-consumer.md | 8 +-- docs/module-graph.md | 9 +++- .../2026-07-04-fold-stdio-ui-helper.md | 2 +- knip.json | 4 ++ packages/ui/README.md | 3 +- packages/ui/stdio-agent/README.md | 2 +- packages/ui/stdio-agent/package.json | 6 ++- packages/ui/stdio-agent/src/index.ts | 8 +-- .../ui/stdio-agent/tests/built-bin.e2e.ts | 1 + packages/ui/stdio-agent/tsconfig.json | 3 ++ packages/ui/stdio/README.md | 22 ++++++++ packages/ui/stdio/package.json | 42 +++++++++++++++ .../src/stdio-chat.ts => stdio/src/index.ts} | 34 +++++++++++-- packages/ui/stdio/tests/plugin-shape.spec.ts | 19 +++++++ .../tests/readline.spec.ts | 4 +- .../tests/stdio.spec.ts} | 51 ++++++++++++++++++- packages/ui/stdio/tsconfig.json | 30 +++++++++++ pnpm-lock.yaml | 28 ++++++++++ tsconfig.build.json | 1 + tsconfig.json | 1 + 21 files changed, 272 insertions(+), 22 deletions(-) create mode 100644 packages/ui/stdio/README.md create mode 100644 packages/ui/stdio/package.json rename packages/ui/{stdio-agent/src/stdio-chat.ts => stdio/src/index.ts} (92%) create mode 100644 packages/ui/stdio/tests/plugin-shape.spec.ts rename packages/ui/{stdio-agent => stdio}/tests/readline.spec.ts (93%) rename packages/ui/{stdio-agent/tests/stdio-chat.spec.ts => stdio/tests/stdio.spec.ts} (94%) create mode 100644 packages/ui/stdio/tsconfig.json diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 69fad2238e..4fa75db879 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -608,6 +608,22 @@ export interface Config { 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` ```ts config-catalog diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index bdf1c6e320..0da48baab6 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,8 +7,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-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-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`](../packages/ui/stdio) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | @@ -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/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-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/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) | @@ -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) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:131`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:137`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 19d471f175..950454bc1f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -98,6 +98,7 @@ flowchart TD pkg_jsonrpc["jsonrpc"] pkg_jsonrpc_agent["jsonrpc-agent"] pkg_permission["permission"] + pkg_stdio["stdio"] pkg_stdio_agent["stdio-agent"] pkg_tool_ask_user["tool-ask-user"] pkg_user_approval["user-approval"] @@ -205,6 +206,10 @@ flowchart TD pkg_permission --> pkg_sandbox pkg_permission --> pkg_session 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_llm pkg_agent_loop --> pkg_scope @@ -333,6 +338,7 @@ flowchart TD pkg_stdio_agent --> pkg_llm pkg_stdio_agent --> pkg_session pkg_stdio_agent --> pkg_session_persistence_jsonl + pkg_stdio_agent --> pkg_stdio pkg_stdio_agent --> pkg_tool_ask_user pkg_stdio_agent --> pkg_tools pkg_stdio_agent --> pkg_user_interaction @@ -385,6 +391,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) | | [`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) | +| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`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) | @@ -410,4 +417,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-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) | -| [`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) | diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index 2c7626619b..634e8ac6ca 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -10,7 +10,7 @@ The boundary bought package metadata, workspace and tsconfig references, module- ## 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. diff --git a/knip.json b/knip.json index 5b8fc7f436..52956ef21a 100644 --- a/knip.json +++ b/knip.json @@ -85,6 +85,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/ui/stdio": { + "entry": ["tests/**/*.spec.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/ui/jsonrpc-agent": { "project": ["src/**/*.ts"] }, diff --git a/packages/ui/README.md b/packages/ui/README.md index 02dfcfbdce..29ff591ee5 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -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` | | `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`) | +| `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`) | | `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-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) | -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. diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index 8cd97f5cc7..5a594d0701 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -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-user-interaction` | the human question/answer seam used by confirmation tools | | `@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`. diff --git a/packages/ui/stdio-agent/package.json b/packages/ui/stdio-agent/package.json index fcd432a9fd..73a25f8c57 100644 --- a/packages/ui/stdio-agent/package.json +++ b/packages/ui/stdio-agent/package.json @@ -38,9 +38,10 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-agent-core": "^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-stdio": "^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", "cordis": "^4.0.0-rc.6", "schemastery": "^3.17.0" @@ -55,9 +56,10 @@ "@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-stdio": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "cordis": "^4.0.0-rc.6", "schemastery": "^3.17.0" diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index cd8167d658..fb125cec56 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -1,8 +1,8 @@ /** * 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 - * in-package `stdio-chat` module), JSONL session persistence, and a pre-created `main` agent - * the UI drives. + * coupled front-door cluster a terminal chat needs — a console logger, the independently + * packaged readline UI, JSONL session persistence, the user-interaction seam with its + * `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 * Loader plugin intentionally exposes named exports only; a default export * 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 UserInteractionService from '@deepseek-ai/dsh-user-interaction' 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' diff --git a/packages/ui/stdio-agent/tests/built-bin.e2e.ts b/packages/ui/stdio-agent/tests/built-bin.e2e.ts index ddd7ca454e..1702e31772 100644 --- a/packages/ui/stdio-agent/tests/built-bin.e2e.ts +++ b/packages/ui/stdio-agent/tests/built-bin.e2e.ts @@ -24,6 +24,7 @@ const dshPackages = [ 'bash/tool-bash', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl', 'ui/stdio-agent', + 'ui/stdio', 'ui/tool-ask-user', 'ui/user-interaction', ] const vendorPackages = [ 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', diff --git a/packages/ui/stdio-agent/tsconfig.json b/packages/ui/stdio-agent/tsconfig.json index 6f30c1558e..b0bfa760c3 100644 --- a/packages/ui/stdio-agent/tsconfig.json +++ b/packages/ui/stdio-agent/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../user-interaction" }, + { + "path": "../stdio" + }, { "path": "../tool-ask-user" }, diff --git a/packages/ui/stdio/README.md b/packages/ui/stdio/README.md new file mode 100644 index 0000000000..2bd4995d86 --- /dev/null +++ b/packages/ui/stdio/README.md @@ -0,0 +1,22 @@ +# @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 +``` diff --git a/packages/ui/stdio/package.json b/packages/ui/stdio/package.json new file mode 100644 index 0000000000..3b00dc6625 --- /dev/null +++ b/packages/ui/stdio/package.json @@ -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" + } +} diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio/src/index.ts similarity index 92% rename from packages/ui/stdio-agent/src/stdio-chat.ts rename to packages/ui/stdio/src/index.ts index 60aba12026..1e665381ce 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio/src/index.ts @@ -2,7 +2,11 @@ * 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 * 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' @@ -26,8 +30,6 @@ export const inject = ['agents', 'userInteraction'] export interface Config { /** Banner printed once on start, before the first `> ` prompt. */ 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'`. */ agent?: string } @@ -350,16 +352,38 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt }, '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 - * {@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` * directly. This thin wrapper is exercised end-to-end by the keyless * 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 */ export function apply(ctx: Context, config: Config): void { - createStdioChat(ctx, config, { + mountStdio(ctx, config, { input: process.stdin, output: process.stdout, exit: code => process.exit(code), diff --git a/packages/ui/stdio/tests/plugin-shape.spec.ts b/packages/ui/stdio/tests/plugin-shape.spec.ts new file mode 100644 index 0000000000..5b2b35f65e --- /dev/null +++ b/packages/ui/stdio/tests/plugin-shape.spec.ts @@ -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 + 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') + }) +}) diff --git a/packages/ui/stdio-agent/tests/readline.spec.ts b/packages/ui/stdio/tests/readline.spec.ts similarity index 93% rename from packages/ui/stdio-agent/tests/readline.spec.ts rename to packages/ui/stdio/tests/readline.spec.ts index a958c435c1..638e98bf59 100644 --- a/packages/ui/stdio-agent/tests/readline.spec.ts +++ b/packages/ui/stdio/tests/readline.spec.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events' import type { Readable, Writable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' 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 reader = new EventEmitter() as EventEmitter & { close(): void } @@ -33,7 +33,7 @@ function fakeRuntime(inputIsTTY: boolean, outputIsTTY: boolean): StdioRuntime { describe('createStdioChat readline mode', () => { 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) createStdioChat(fakeContext(), {}, tty) diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio/tests/stdio.spec.ts similarity index 94% rename from packages/ui/stdio-agent/tests/stdio-chat.spec.ts rename to packages/ui/stdio/tests/stdio.spec.ts index f734acfd0d..7bb6a6f245 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio/tests/stdio.spec.ts @@ -6,7 +6,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' 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 @@ -93,6 +93,55 @@ function flushExit(): Promise { 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', () => { it('writes the welcome banner and prompt on start', async () => { const { out } = await setup() diff --git a/packages/ui/stdio/tsconfig.json b/packages/ui/stdio/tsconfig.json new file mode 100644 index 0000000000..00cb815a75 --- /dev/null +++ b/packages/ui/stdio/tsconfig.json @@ -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" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1905b80887..eee88947bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-stdio': + specifier: workspace:^ + version: link:../stdio '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -1392,6 +1395,31 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + 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.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + packages/ui/stdio-agent: devDependencies: '@cordisjs/plugin-include': diff --git a/tsconfig.build.json b/tsconfig.build.json index 591955b260..3bd15b81ea 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -61,6 +61,7 @@ { "path": "./packages/ui/app-boot" }, { "path": "./packages/ui/jsonrpc" }, { "path": "./packages/ui/jsonrpc-agent" }, + { "path": "./packages/ui/stdio" }, { "path": "./packages/ui/stdio-agent" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" }, diff --git a/tsconfig.json b/tsconfig.json index e97a8295a5..a243abaa1c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -72,6 +72,7 @@ { "path": "./packages/ui/app-boot" }, { "path": "./packages/ui/jsonrpc" }, { "path": "./packages/ui/jsonrpc-agent" }, + { "path": "./packages/ui/stdio" }, { "path": "./packages/ui/stdio-agent" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" }, From 8e892095a16682ecb1a3fc491ce96d0eaaaf5fdb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:20:48 +0800 Subject: [PATCH 11/13] fix: readme and dep --- examples/acp-agent/cordis.snapshot.yml | 10 ++++++++++ packages/ui/stdio/README.md | 20 ++++++++++++++++++++ pnpm-lock.yaml | 6 +++--- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index f69770b4dd..90a2fa6a2a 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -15,6 +15,16 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' 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: - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' diff --git a/packages/ui/stdio/README.md b/packages/ui/stdio/README.md index 2bd4995d86..b7d320880d 100644 --- a/packages/ui/stdio/README.md +++ b/packages/ui/stdio/README.md @@ -20,3 +20,23 @@ The plugin seeds display labels from the live agent registry, then tracks `agent 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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eee88947bc..3cab8bad46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,9 +172,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-stdio': - specifier: workspace:^ - version: link:../stdio '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -1449,6 +1446,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-stdio': + specifier: workspace:^ + version: link:../stdio '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt From e8c31e054d3e50ab4ecdcc811e8fd81df4afea51 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:53:01 +0800 Subject: [PATCH 12/13] fix: pnpm dep after merge --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bfeee5051..95065391d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1415,7 +1415,7 @@ importers: version: link:../user-interaction cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) packages/ui/stdio-agent: devDependencies: From 600af3ca7986c0af9a463384e4473f6d2f330d5d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:06:08 +0800 Subject: [PATCH 13/13] chore: reconcile loader smoke lockfile --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb5e9a6e00..b1010bce7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1143,7 +1143,7 @@ importers: devDependencies: cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/support/subagent-mock: dependencies: