diff --git a/docs/cookbook/adding-an-llm-adapter.md b/docs/cookbook/adding-an-llm-adapter.md index aae6b0bb8b..fb59969e52 100644 --- a/docs/cookbook/adding-an-llm-adapter.md +++ b/docs/cookbook/adding-an-llm-adapter.md @@ -27,7 +27,7 @@ Registration is effect-based (HMR-safe); one adapter per model name — duplicat - Allocate block `index`es in first-seen stream order; reuse the index for every delta of the same block. - Errors have exactly two sanctioned paths: THROW from `stream()` (transport and protocol failures — use `LlmError` with a stable code), or end the stream with `finish {kind: 'error' | 'aborted'}` (provider in-band failures). Consumers handle both; pick per failure class and document it. - Honor `options.signal` (pass it to fetch / your SDK). -- `prefill` and other unsupported `GenerateOptions` fields: throw `LlmError(..., 'UNSUPPORTED')` rather than silently dropping. +- A `GenerateOptions` field your provider cannot honor (e.g. a `stop` list on a provider without stop sequences): throw `LlmError(..., 'UNSUPPORTED')` rather than silently dropping it. Provider-specific request knobs (thinking modes, effort levels) belong in the ADAPTER's Config, not in `GenerateOptions` — the core vocabulary stays provider-neutral. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index e8f5c05f10..7f38abe985 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -130,8 +130,6 @@ interface GenerateOptions { system?: string /** Tool schemas (adapters map to the provider's `tools` field). */ tools?: ToolSchema[] - /** Assistant prefix continuation (prefill). */ - prefill?: ContentBlock[] temperature?: number maxTokens?: number /** @@ -180,7 +178,6 @@ interface ToolSchema { description: string /** JSON Schema object for the arguments. */ parameters: Record - strict?: boolean } ``` diff --git a/docs/rfc/README.md b/docs/rfc/README.md index ce1f06d148..68564cfd64 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -52,7 +52,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | -| [Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path](proposed/simplification/2026-07-04-drop-inert-request-knobs.md) | 2026-07-04 | | [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | | [Prune write-only fields and a dead routing knob from the fs seam](proposed/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 | | [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 | @@ -114,6 +113,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | | [Drop the `image` content block until a path can honor it](implemented/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 | +| [Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path](implemented/simplification/2026-07-04-drop-inert-request-knobs.md) | 2026-07-04 | | [Drop the unconsumed web observation surface — the `providers-change` event and the status methods](implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) | 2026-07-04 | | [Fold the stdio UI helper into the stdio app](implemented/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 | | [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 | diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md index 619ada6f13..bc6d71e6e9 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -16,6 +16,6 @@ In-session context injection (`context/message`, `steering/message`) renders as ## Consequences -- Reasoning and prefill have a home without provider contortions. Multimodal content deliberately has NO core block type: the core set is limited to blocks every shipping path honors, and a multimodal feature adds its block type through the merge-extensible map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction — see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). Block cache hints likewise have no core field: DeepSeek prompt caching is automatic, so no shipping adapter can transmit a hint; a caching feature adds a `cache` field together with the adapter that honors it — see [the producer-less-variants RFC](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md). +- Reasoning has a home without provider contortions. Multimodal content deliberately has NO core block type: the core set is limited to blocks every shipping path honors, and a multimodal feature adds its block type through the merge-extensible map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction — see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). Block cache hints likewise have no core field: DeepSeek prompt caching is automatic, so no shipping adapter can transmit a hint; a caching feature adds a `cache` field together with the adapter that honors it — see [the producer-less-variants RFC](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md). Assistant-prefix continuation (prefill) likewise has no request field: DeepSeek's chat-prefix completion is a Beta feature on a base URL neither shipping adapter targets, so a prefill feature adds `GenerateOptions.prefill` together with the adapter that honors it — see [the inert-request-knobs RFC](../simplification/2026-07-04-drop-inert-request-knobs.md). - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. - IDs that cross package boundaries are branded (`CallId`, `SessionId`, `AgentId`) — nominal typing at zero runtime cost. diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md new file mode 100644 index 0000000000..3724d680f5 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md @@ -0,0 +1,33 @@ +# RFC: Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path + +Status: implemented (proposed and accepted 2026-07-04) + +## Problem + +Two request-contract knobs rode the whole request pipeline, yet neither could do anything: + +- **`prefill`** (`packages/llm/llm/src/types.ts`) had no production setter — the loop assembles `model`/`system`/`tools`/`messages` plus `sessionId`/`signal`, and the compaction backend adds only `maxTokens` — and BOTH adapters rejected it: `packages/llm/llm-deepseek/src/serialize.ts` and `packages/llm/llm-pi-ai/src/adapter.ts` each threw `LlmError('UNSUPPORTED')` on a non-undefined `prefill`. The field's entire observable behavior was two throws, each pinned by one adapter test. DeepSeek's chat-prefix completion is a Beta feature on a base URL neither adapter targets. +- **`strict`** (`ToolSchema`, same file) was threaded through `DefineToolOptions`/`defineTool` (`packages/core/tools/src/schema.ts`), the registry's `schemas()` allowlist (`packages/core/tools/src/index.ts`), the deepseek wire mapping (`packages/llm/llm-deepseek/src/serialize.ts`, whose wire-type note recorded that strict mode requires the `/beta` base URL the adapter does not use), a per-tool payload-patching pass in `packages/llm/llm-pi-ai/src/adapter.ts`, and a conditional `Strict:` row in the tool-catalog renderer (`scripts/gen-tool-catalog.ts`). No shipped tool set it — `rg` across every `tool-*` package src and `examples/` found zero `strict:` producers; the only setters were dsh-tools unit tests. + +Both knobs were adapter-symmetric, so removal shed them from both twins together — the [twin-adapter design](../architecture/2026-06-13-twin-llm-adapters.md) is untouched. + +## Decision + +- `prefill` is removed from `GenerateOptions`, along with both adapters' UNSUPPORTED guards, the tests pinning the throws, the paste line in [core.md](../../../core-data-structures/core.md), and the adapter README rows documenting the rejection. The cookbook's UNSUPPORTED guidance ([adding-an-llm-adapter.md](../../../cookbook/adding-an-llm-adapter.md)) states the rule generically — a `GenerateOptions` field your provider cannot honor throws `LlmError(..., 'UNSUPPORTED')` — instead of using prefill as the example. The [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s consequences record prefill as producer-gated rather than as having a home, per [implemented/AGENTS.md](../AGENTS.md). +- `strict` is removed from `ToolSchema`, `DefineToolOptions`, `defineTool`, the `schemas()` allowlist, the deepseek serializer branch and its wire-type field, and the tool-catalog renderer's `Strict:` row. The pi-ai payload fixup is simplified to the unconditional scrub of pi-ai's own per-tool strict default (pi-ai stamps `strict: false` on every serialized tool; the hand-rolled twin sends no such field, so the scrub survives for wire parity, pinned by its serializer test). The setter tests and the core.md paste line are gone; both `GenerateOptions` and `ToolSchema` keep their rows in `scripts/type-equiv.manifest.json`, since each type survives minus a field. + +This RFC deliberately does NOT touch `temperature`, `stop`, or `maxTokens`: those are honored end-to-end by both adapters and are the natural first targets of a request-mutating hook plugin on `agent/request`. + +## Why not keep them? + +"An explicit UNSUPPORTED throw is honest contract behavior" — but a knob whose only implementation across both twins is rejection promises nothing, and deleting it upgrades the failure mode: an accidental setter becomes a compile error instead of a runtime throw. "Strict schema adherence is an officially documented provider feature with complete plumbing" — but a knob is not product surface until a shipped tool sets it AND an endpoint honors it; today neither is true. Each returns with its first real producer: `prefill` together with an adapter that implements chat-prefix completion (and a stated policy for adapters that do not), `strict` together with a tool that wants it and a beta-endpoint story. + +## Acceptance criteria + +- `rg prefill` returns only RFC records (this one and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s producer-gated consequence); a tool-schema-scoped `rg strict` returns only this RFC, the surviving pi-ai scrub, and unrelated prose such as `strictEqual`. +- Both adapters compile and their contract tests pass without the guards; the pi-ai fixup still scrubs the library's strict default (wire parity pinned by its serializer tests). +- Doc pastes and the type-equiv manifest in sync; `pnpm run doc-sync` green. + +## Risks + +The shipped hook bridges set no request fields at all, and a request-mutating plugin (an `agent/request` waterfall listener) would reach for `temperature`/`stop` (kept, working), not a field adapters reject. If chat-prefix completion or strict mode become product features, the re-add lands with the adapter/endpoint work, where the contract can say what actually happens rather than "everyone throws". diff --git a/docs/rfc/proposed/simplification/2026-07-04-drop-inert-request-knobs.md b/docs/rfc/proposed/simplification/2026-07-04-drop-inert-request-knobs.md deleted file mode 100644 index 60375ecf8c..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-04-drop-inert-request-knobs.md +++ /dev/null @@ -1,33 +0,0 @@ -# RFC: Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path - -Status: proposed - -## Problem - -Two request-contract knobs ride the whole request pipeline, yet neither can do anything today: - -- **`prefill`** (`packages/llm/llm/src/types.ts`) has no production setter — the loop assembles `model`/`system`/`tools`/`messages` plus `sessionId`/`signal`, and the compaction backend adds only `maxTokens` — and BOTH adapters reject it: `packages/llm/llm-deepseek/src/serialize.ts` and `packages/llm/llm-pi-ai/src/adapter.ts` each throw `LlmError('UNSUPPORTED')` on a non-undefined `prefill`. The field's entire observable behavior is two throws, each pinned by one adapter test. DeepSeek's chat-prefix completion is a Beta feature on a base URL neither adapter targets. -- **`strict`** (`ToolSchema`, same file) is threaded through `DefineToolOptions`/`defineTool` (`packages/core/tools/src/schema.ts`), the registry's `schemas()` allowlist (`packages/core/tools/src/index.ts`), the deepseek wire mapping (`packages/llm/llm-deepseek/src/serialize.ts`, whose wire-type note records that strict mode requires the `/beta` base URL the adapter does not use), and a per-tool payload-patching pass in `packages/llm/llm-pi-ai/src/adapter.ts`. No shipped tool sets it — `rg` across every `tool-*` package src and `examples/` finds zero `strict:` producers; the only setters are dsh-tools unit tests. - -Both knobs are adapter-symmetric, so removal sheds them from both twins together — the [twin-adapter design](../../implemented/architecture/2026-06-13-twin-llm-adapters.md) is untouched. - -## Proposal - -- Remove `prefill` from `GenerateOptions`, both adapters' UNSUPPORTED guards, the tests pinning the throws, the paste lines in [core.md](../../../core-data-structures/core.md), the adapter README rows documenting the rejection, and the cookbook line using prefill as the UNSUPPORTED example ([adding-an-llm-adapter.md](../../../cookbook/adding-an-llm-adapter.md)); amend the [content-block vocabulary RFC](../../implemented/architecture/2026-06-11-content-block-vocabulary.md)'s consequence line naming prefill as having a home, per [implemented/AGENTS.md](../../implemented/AGENTS.md). -- Remove `strict` from `ToolSchema`, `DefineToolOptions`, `defineTool`, and the `schemas()` allowlist; drop the deepseek serializer branch; simplify the pi-ai payload fixup to the unconditional scrub of pi-ai's own strict default (that half exists for wire parity with the hand-rolled twin and survives); drop the setter tests and the core.md paste line. - -This RFC deliberately does NOT touch `temperature`, `stop`, or `maxTokens`: those are honored end-to-end by both adapters and are the natural first targets of a request-mutating hook plugin on `agent/request`. - -## Why not keep them? - -"An explicit UNSUPPORTED throw is honest contract behavior" — but a knob whose only implementation across both twins is rejection promises nothing, and deleting it upgrades the failure mode: an accidental setter becomes a compile error instead of a runtime throw. "Strict schema adherence is an officially documented provider feature with complete plumbing" — but a knob is not product surface until a shipped tool sets it AND an endpoint honors it; today neither is true. Each returns with its first real producer: `prefill` together with an adapter that implements chat-prefix completion (and a stated policy for adapters that do not), `strict` together with a tool that wants it and a beta-endpoint story. - -## Acceptance criteria - -- `rg prefill` and a tool-schema-scoped `rg strict` return only this RFC (and unrelated prose such as `strictEqual`). -- Both adapters compile and their contract tests pass without the guards; the pi-ai fixup still scrubs the library's strict default (wire parity pinned by its serializer tests). -- Doc pastes and the type-equiv manifest in sync; `pnpm run doc-sync` green. - -## Risks - -The shipped hook bridges set no request fields at all, and a request-mutating plugin (an `agent/request` waterfall listener) would reach for `temperature`/`stop` (kept, working), not a field adapters reject. If chat-prefix completion or strict mode become product features, the re-add lands with the adapter/endpoint work, where the contract can say what actually happens rather than "everyone throws". diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 063e14708e..dd0ed918db 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -317,21 +317,20 @@ export class ToolRegistry extends Service { /** * Return all registered tool schemas — exactly the model-facing fields - * (`name`, `description`, `parameters`, and `strict` when set), as sent to the - * model via the system-prompt assembly. Constructed EXPLICITLY rather than by - * stripping known non-schema members: a `ToolDefinition` also carries - * `execute` and the optional `presentCall`/`presentResult` UI callbacks, and - * those (especially the functions) must never leak into a model request. An - * allowlist can't drift when a new non-schema member is added to the - * definition; a denylist (rest-destructure) would silently leak it. + * (`name`, `description`, `parameters`), as sent to the model via the + * system-prompt assembly. Constructed EXPLICITLY rather than by stripping + * known non-schema members: a `ToolDefinition` also carries `execute` and the + * optional `presentCall`/`presentResult` UI callbacks, and those (especially + * the functions) must never leak into a model request. An allowlist can't + * drift when a new non-schema member is added to the definition; a denylist + * (rest-destructure) would silently leak it. * @returns one deep-cloned schema per registered tool, in registration order. */ schemas(): ToolSchema[] { - return [...this.store.values()].map(({ name, description, parameters, strict }): ToolSchema => ({ + return [...this.store.values()].map(({ name, description, parameters }): ToolSchema => ({ name, description, parameters: structuredClone(parameters), - ...strict !== undefined ? { strict } : {}, })) } diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 0539c1f07b..9149241225 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -312,8 +312,6 @@ export interface DefineToolOptions { * free for the same replay reason. See {@link ToolResultView}. */ presentResult?(args: InferArgs, result: ToolResult): ToolResultView | undefined - /** Whether the tool requires structured output (default false). */ - strict?: boolean } /** @@ -355,7 +353,6 @@ export function defineTool(options: DefineToolOptions): name: options.name, description: options.description, parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record, - ...options.strict !== undefined ? { strict: options.strict } : {}, async execute(args: unknown, exec: ToolExecution): Promise { // Validate the model-generated args before the typed body runs. On // mismatch we throw ToolArgsError; the registry turns it into an diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index f034c0529f..05236dd322 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -103,15 +103,4 @@ describe('gen-tool-catalog render', () => { expect(md).toContain('```json') expect(md).toContain('Source: [`packages/demo/tool-demo/src/index.ts`]') }) - - it('renders the strict flag when a schema sets it', () => { - const catalog: ToolCatalog = [ - { - pkg: '@deepseek-ai/dsh-tool-demo', - source: 'packages/demo/tool-demo/src/index.ts', - schemas: [{ name: 'demo', description: '', parameters: { type: 'object', properties: {} }, strict: true }], - }, - ] - expect(render(catalog)).toContain('Strict: `true`') - }) }) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index ca63338258..09158b8398 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -62,18 +62,6 @@ describe('ToolRegistry', () => { expect(schema.execute).toBeUndefined() }) - it('schemas() preserves `strict` when set (allowlist keeps the model-facing fields)', async () => { - const ctx = await setup() - ctx.tools.register(defineTool({ - name: 'strict-tool', - description: 'd', - parameters: { x: { type: 'string', required: true } }, - strict: true, - async execute() { return [] }, - })) - expect(ctx.tools.schemas()[0]).toMatchObject({ name: 'strict-tool', strict: true }) - }) - it('executes a tool and returns its content', async () => { const ctx = await setup() ctx.tools.register(echoTool) @@ -611,44 +599,6 @@ describe('schema DSL edge cases', () => { }) }) - it('defineTool passes through strict flag when set to true', () => { - const tool = defineTool({ - name: 'strict-tool', - description: 'A strict tool', - parameters: { input: { type: 'string' } }, - strict: true, - async execute(args) { - return [{ type: 'text' as const, text: args.input ?? '' }] - }, - }) - expect(tool.strict).toBe(true) - }) - - it('defineTool omits strict when not provided', () => { - const tool = defineTool({ - name: 'non-strict-tool', - description: 'A non-strict tool', - parameters: { input: { type: 'string' } }, - async execute(args) { - return [{ type: 'text' as const, text: args.input ?? '' }] - }, - }) - expect('strict' in tool).toBe(false) - }) - - it('defineTool strict=false is included', () => { - const tool = defineTool({ - name: 'explicitly-non-strict', - description: 'Explicitly non-strict', - parameters: { input: { type: 'string' } }, - strict: false, - async execute(args) { - return [{ type: 'text' as const, text: args.input ?? '' }] - }, - }) - expect(tool.strict).toBe(false) - }) - it('handles enum and default together in one property', () => { const spec = { level: { type: 'string', enum: ['low', 'high'], default: 'low' }, diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 66d6ae3e9b..71f38993ca 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -28,12 +28,10 @@ A second, independent implementation of the same seam exists in `@deepseek-ai/ds - Streaming only (`stream_options.include_usage` always on). `usage` may arrive attached to the finish chunk or as a trailing usage-only chunk — the translator defers both to `[DONE]`, so `usage` always precedes `finish` and nothing follows `finish`. - The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block). - **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens). -- `strict` on tool schemas passes through (officially Beta; the public API wants the `/beta` base URL for it, the internal endpoint accepts it directly). - Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric. ## Limitations (MVP, documented deliberately) -- `prefill` throws `LlmError('UNSUPPORTED')` — DeepSeek's chat-prefix completion is a Beta feature on the `/beta` base URL; future work. - `tool_choice` is not mapped (not part of the core vocabulary). ## Errors diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index c4e9dcb0a0..bbca37223f 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -15,7 +15,6 @@ * @module dsh-llm-deepseek/serialize */ -import { LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { WireMessage, WireRequest, WireTool } from './types.ts' @@ -98,19 +97,8 @@ export function serializeMessages(messages: Message[]): WireMessage[] { return wire } -/** - * Build the full wire request. Throws `LlmError('UNSUPPORTED')` for - * `prefill` (DeepSeek's chat-prefix completion is a Beta feature on a - * different base URL — see README). - */ +/** Build the full wire request. */ export function serializeRequest(options: GenerateOptions, defaults: RequestDefaults = {}): WireRequest { - if (options.prefill !== undefined) { - throw new LlmError( - 'prefill is not supported by the DeepSeek adapter (Beta chat-prefix completion is future work)', - 'UNSUPPORTED', - ) - } - const messages: WireMessage[] = [] if (options.system !== undefined) { messages.push({ role: 'system', content: options.system }) @@ -123,8 +111,6 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa name: tool.name, description: tool.description, parameters: tool.parameters, - // strict is officially supported (Beta); pass the tool author's choice. - ...tool.strict !== undefined ? { strict: tool.strict } : {}, }, })) diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index 5c212897fd..072c43babb 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -78,8 +78,6 @@ export interface WireTool { name: string description: string parameters: Record - /** Beta: strict schema adherence (official: requires the /beta base URL). */ - strict?: boolean } } diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 51d4788fe4..3e533f8e7c 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { CallId, LlmError } from '@deepseek-ai/dsh-llm' +import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek' @@ -155,17 +155,17 @@ describe('serializeRequest', () => { expect(wire.stop).toEqual(['END']) }) - it('maps tools with strict passthrough', () => { + it('maps tools to the wire function shape', () => { const wire = serializeRequest(request({ messages: history, tools: [ { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } }, - { name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true }, + { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } }, ], })) expect(wire.tools).toEqual([ { type: 'function', function: { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } } }, - { type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true } }, + { type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } } }, ]) }) @@ -185,17 +185,6 @@ describe('serializeRequest', () => { expect(wire.thinking).toBeUndefined() expect(wire.reasoning_effort).toBeUndefined() }) - - it('rejects prefill with an UNSUPPORTED LlmError', () => { - expect(() => serializeRequest(request({ prefill: [{ type: 'text', text: 'Sure' }] }))) - .toThrow(LlmError) - try { - serializeRequest(request({ prefill: [] })) - expect.unreachable() - } catch (error) { - expect((error as LlmError).code).toBe('UNSUPPORTED') - } - }) }) describe('review fixes: assistant content shapes', () => { diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index ca34a8f586..accd1fbbe3 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -9,7 +9,7 @@ DeepSeek adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](ht - pi-ai hands tool-call `arguments` around as **parsed objects**; the harness keeps raw JSON strings. The adapter patches replay payloads back to the original raw strings before sending them, and re-stringifies parsed output tool calls at `block-end`. - pi-ai reports failures as **in-stream error events** (it never throws mid-stream); these map to `finish {kind:'error'|'aborted'}` chunks — the protocol's other sanctioned error path besides throwing (which llm-deepseek uses). - pi-ai folds reasoning tokens into `usage.output`; there is no separate reasoning count to map. -- pi-ai's options omit some DeepSeek/OpenAI-compatible details; the adapter uses its `onPayload` hook to preserve the harness contract (`stop`, per-tool `strict`, omitted reasoning effort, raw replayed tool arguments). +- pi-ai's options omit some DeepSeek/OpenAI-compatible details; the adapter uses its `onPayload` hook to preserve the harness contract (`stop`, scrubbing pi-ai's own per-tool `strict` default — the hand-rolled twin sends no such field — omitted reasoning effort, raw replayed tool arguments). ## Config @@ -31,7 +31,7 @@ pi-ai declares the openai/anthropic/google/mistral/AWS SDKs as install-time depe ## Limitations -Same MVP contract as llm-deepseek: `prefill` throws `UNSUPPORTED`, `tool_choice` is not mapped. +Same MVP contract as llm-deepseek: `tool_choice` is not mapped. ## Testing diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index e15cce8252..7d61e51eb4 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -13,9 +13,9 @@ import { stream as piStream } from '@earendil-works/pi-ai' import type { Model } from '@earendil-works/pi-ai' -import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import { LlmAdapter } from '@deepseek-ai/dsh-llm' import { CallId } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, StreamChunk, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { toPiContext, toStreamChunks } from './convert.ts' /** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */ @@ -61,7 +61,7 @@ export function buildModel(modelId: string, options: PiAiAdapterOptions): Model< } type Payload = { - tools?: { function?: { name?: unknown; strict?: unknown } }[] + tools?: { function?: { strict?: unknown } }[] messages?: { role?: unknown tool_calls?: { id?: unknown; function?: { arguments?: unknown } }[] @@ -81,10 +81,6 @@ function rawToolArguments(options: GenerateOptions): Map { return raw } -function strictByToolName(tools: ToolSchema[] | undefined): Map { - return new Map((tools ?? []).map(tool => [tool.name, tool.strict])) -} - function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiAiReasoning | undefined): unknown { /* v8 ignore next -- pi-ai onPayload always receives an object; tolerate unusual future hooks defensively */ if (typeof payload !== 'object' || payload === null) return payload @@ -97,16 +93,13 @@ function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiA body.stop = options.stop } - const strictByName = strictByToolName(options.tools) + // pi-ai stamps its own `strict` default on every serialized tool; the + // harness tool contract has no strict field and the hand-rolled twin sends + // none, so scrub it for wire parity. for (const tool of body.tools ?? []) { /* v8 ignore next -- malformed pi-ai payload guard: real tool entries always carry function */ if (tool.function === undefined) continue - const name = tool.function.name - /* v8 ignore next -- malformed pi-ai payload guard: real function entries always carry a string name */ - if (typeof name !== 'string') continue - const strict = strictByName.get(name) - if (strict === undefined) delete tool.function.strict - else tool.function.strict = strict + delete tool.function.strict } const rawById = rawToolArguments(options) @@ -131,9 +124,9 @@ function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiA * * Implementation notes: * - `onPayload` patches provider payload details pi-ai cannot express directly: - * stop sequences, per-tool strict, omitted reasoning effort, and raw replayed - * tool-call arguments. - * - `prefill` throws UNSUPPORTED (same contract as dsh-llm-deepseek). + * stop sequences, scrubbing pi-ai's own per-tool `strict` default (the + * hand-rolled twin sends no such field), omitted reasoning effort, and raw + * replayed tool-call arguments. * - pi-ai reports request failures as in-stream error events; convert.ts * maps them to `finish {kind:'error'|'aborted'}` chunks rather than * throwing — both are sanctioned StreamChunk error paths. @@ -144,13 +137,6 @@ export class PiAiAdapter extends LlmAdapter { } async * stream(options: GenerateOptions): AsyncIterable { - if (options.prefill !== undefined) { - throw new LlmError( - 'prefill is not supported by the pi-ai adapter', - 'UNSUPPORTED', - ) - } - const model = buildModel(options.model, this.options) // Undefined config means "provider default" (DeepSeek: thinking ENABLED), // matching llm-deepseek's omission semantics. pi-ai derives the wire diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 63f9f90456..5df4f50bf4 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -2,7 +2,7 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId, LlmError } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { buildModel, PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { assemble } from './assemble.ts' @@ -149,26 +149,26 @@ describe('PiAiAdapter against a mock server', () => { expect(server.requests[0]).toMatchObject({ stop: ['END'] }) }) - it('preserves per-tool strict exactly through onPayload', async () => { + it('scrubs pi-ai\'s own per-tool strict default through onPayload', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url) await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [], tools: [ - { name: 'strict_true', description: 'true', parameters: {}, strict: true }, - { name: 'strict_false', description: 'false', parameters: {}, strict: false }, - { name: 'strict_omitted', description: 'omitted', parameters: {} }, + { name: 'alpha', description: 'a', parameters: {} }, + { name: 'beta', description: 'b', parameters: {} }, ], }) + // pi-ai stamps `strict` on every serialized tool function; the harness + // contract has none and the hand-rolled twin sends no such field, so the + // payload fixup must have deleted it from every tool. const request = server.requests[0] as { tools: { function: { name: string; strict?: boolean } }[] } - expect(request.tools.map(tool => [tool.function.name, tool.function.strict])).toEqual([ - ['strict_true', true], - ['strict_false', false], - ['strict_omitted', undefined], - ]) - expect('strict' in request.tools[2]!.function).toBe(false) + expect(request.tools.map(tool => tool.function.name)).toEqual(['alpha', 'beta']) + for (const tool of request.tools) { + expect('strict' in tool.function).toBe(false) + } }) it('preserves raw replayed tool-call arguments in the provider payload', async () => { @@ -209,15 +209,6 @@ describe('PiAiAdapter against a mock server', () => { expect(result.finish).toMatchObject({ kind: 'error', code }) }) - it('rejects prefill with UNSUPPORTED', async () => { - const ctx = await harness('http://127.0.0.1:1') - await expect(assemble(ctx,{ - model: 'deepseek-v4-flash', - messages: [], - prefill: [{ type: 'text', text: 'Sure' }], - })).rejects.toThrow(LlmError) - }) - it('registers/unregisters models on the llm service (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 61d4769ffa..7163ddc1d9 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -163,7 +163,6 @@ export interface ToolSchema { description: string /** JSON Schema object for the arguments. */ parameters: Record - strict?: boolean } /** A single model request, fully assembled. */ @@ -174,8 +173,6 @@ export interface GenerateOptions { system?: string /** Tool schemas (adapters map to the provider's `tools` field). */ tools?: ToolSchema[] - /** Assistant prefix continuation (prefill). */ - prefill?: ContentBlock[] temperature?: number maxTokens?: number /** diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 3298c507f8..0ffc3565f4 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -220,7 +220,6 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES function renderTool(schema: ToolSchema, source: string): string[] { const out = [`### \`${schema.name}\``, ''] if (schema.description) out.push(schema.description, '') - if (schema.strict !== undefined) out.push(`Strict: \`${String(schema.strict)}\``, '') out.push('```json', JSON.stringify(schema.parameters, null, 2), '```', '') out.push(`Source: [\`${source}\`](../../${source})`, '') return out