diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index e2f3cd35f5..e8f5c05f10 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -107,7 +107,6 @@ Where a message came from is itself a merge-extensible sum type: interface MessageSourceMap { user: { kind: 'user' } plugin: { kind: 'plugin'; plugin: string } - agent: { kind: 'agent'; agentId: string } } ``` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 900fdc54fa..2ef01596d4 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -164,7 +164,6 @@ Everything else (`turn/*`, `step/*`) is structural and does not project into a m ```ts type-equiv interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } - continuation: { kind: 'continuation' } /** * An out-of-band context injection (`agent.inject()`) made while the agent * was idle. The loop wraps the injected `context/message` in a one-shot turn diff --git a/docs/rfc/README.md b/docs/rfc/README.md index f0987d2e65..0c6944bf40 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -56,7 +56,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Drop the unconsumed web observation surface — the `providers-change` event and the status methods](proposed/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) | 2026-07-04 | | [Fold the stdio UI helper into the stdio app](proposed/simplification/2026-07-04-fold-stdio-ui-helper.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 producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.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 | | [Remove the `agent/steering` mirror emit](proposed/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 | | [Share the app bins' boot glue instead of maintaining twin copies](proposed/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 | @@ -120,6 +119,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 | +| [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 | ### Architecture 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 d2d2162588..619ada6f13 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, prefill, and cache hints 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). +- 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). - 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-prune-producerless-vocabulary-variants.md b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md new file mode 100644 index 0000000000..b0fedc990e --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md @@ -0,0 +1,31 @@ +# RFC: Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger) + +Status: implemented (proposed and accepted 2026-07-04) + +## Problem + +The merge-extensible vocabulary maps are designed to grow by declaration merging, and the codebase already states the admission policy on `TurnEndReasonMap` (`packages/core/session/src/types.ts`): a variant like `refusal` is "deliberately omitted until" an adapter or loop first emits it. Three declared vocabulary items violated that policy — each had no producer and no consumer, and two had not even a test: + +- **`CacheHint` and its `cache?: CacheHint` block fields** on `TextBlock`/`ToolResultBlock` (`packages/llm/llm/src/types.ts`; the image block carried a third such field, which left with it — see [the drop-image RFC](2026-07-04-drop-image-content-block.md)). Nothing constructed a block with `cache:` anywhere — src, tests, and doc pastes all came up empty — and neither adapter read `.cache`: DeepSeek prompt caching is automatic, so the adapters map `prompt_cache_hit_tokens` OUT of responses without ever sending a hint IN. This was Anthropic-style `cache_control` surface with no provider that could honor it. +- **`MessageSourceMap.agent`** (`{ kind: 'agent'; agentId: string }`, same file). Zero constructors, tests included. Its intended producer shipped without it: the subagent backends send the parent's prompt to the child with no `source`, so it logs as `{ kind: 'user' }`, and the generic envelope renderer interpolates `source.kind` without ever routing on it. +- **`TurnTriggerMap.continuation`** (`packages/core/session/src/types.ts`). The loop structurally cannot emit it — continuation happens *within* a turn as further steps, never as a new turn — and it constructs only `message` and `injection` triggers. The only writer was one hand-built test fixture needing an arbitrary non-message trigger (`packages/support/llm-replay/tests/llm-replay.spec.ts`), which an `injection` trigger serves equally; the only production trigger reader, the ACP bridge, filters on `kind === 'message'`. + +## Decision + +`CacheHint` with both `cache?` fields, the `agent` message-source variant, and the `continuation` turn-trigger variant are deleted. The llm-replay fixture uses an `injection` trigger (any non-`message` trigger serves its purpose). Updated in the same change: the type-equiv pastes in [core.md](../../../core-data-structures/core.md) and [session.md](../../../core-data-structures/session.md) (`scripts/type-equiv.manifest.json` is untouched — both pasted symbols survive, each minus a member), and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s consequence line that had named cache hints as having a home, per [implemented/AGENTS.md](../AGENTS.md). + +Each variant returns the day it gains a real producer, exactly as the maps are designed to grow: a caching feature re-adds `cache` together with the adapter that transmits it; subagent attribution re-adds `agent` together with the backend that stamps it and a consumer that routes on it; an auto-continue feature that genuinely starts new turns re-adds `continuation` with the plugin that emits it. + +## Why not keep them? + +The [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md) listed "cache hints … have a home" as a design consequence, and reserved slots do advertise intent. But an empty slot is contract surface every implementation and consumer must consider (must my adapter honor `cache`? must my renderer route `agent` sources?), and the sibling map's own JSDoc already rejects reservation-without-emitter — `refusal` and `max_turn_requests` are named as variants to add *when something first emits them*, not declared in advance. Holding already-declared dead variants to the same standard makes the vocabulary mean something: if it is in the map, something produces it. + +## Acceptance criteria + +- `rg` for `CacheHint`, the `agent` message-source spelling, and the `continuation` trigger spelling returns only RFC records (this one, and [the drop-image RFC](2026-07-04-drop-image-content-block.md)'s account of the image block's own `cache` field). +- The core-data-structures pastes and the type-equiv manifest are in sync (`pnpm run doc-sync` green). +- The fixture asserts the same replay behavior with an `injection` trigger; the suite is green. + +## Risks + +None operational — nothing could construct these values. The mirror-event removals (recorded in [the boundary-mirror RFC](2026-06-20-remove-agent-boundary-mirror-events.md) and [the stream-chunk RFC](2026-07-02-remove-stream-chunk-mirror.md)) touch only transient `agent/*` events, never the durable vocabulary, so there is no collision. Elsewhere in the vocabulary the admission policy already holds: `rejected`, `prompt/blocked`, and `hook/invoked`/`hook/result` each have live producers — this RFC extends the same bar to the three variants that lacked one. [The image-block removal](2026-07-04-drop-image-content-block.md) shipped first, taking the image block's `cache?` field with it; this change removed the two that remained. diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md b/docs/rfc/proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md deleted file mode 100644 index 38565c9214..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md +++ /dev/null @@ -1,31 +0,0 @@ -# RFC: Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger) - -Status: proposed - -## Problem - -The merge-extensible vocabulary maps are designed to grow by declaration merging, and the codebase already states the admission policy on `TurnEndReasonMap` (`packages/core/session/src/types.ts`): a variant like `refusal` is "deliberately omitted until" an adapter or loop first emits it. Three declared vocabulary items violate that policy — each has no producer and no consumer, and two have not even a test: - -- **`CacheHint` and the three `cache?: CacheHint` fields** on `TextBlock`/`ToolResultBlock`/`ImageBlock` (`packages/llm/llm/src/types.ts`). Nothing constructs a block with `cache:` anywhere — src, tests, and doc pastes all come up empty — and neither adapter reads `.cache`: DeepSeek prompt caching is automatic, so the adapters map `prompt_cache_hit_tokens` OUT of responses without ever sending a hint IN. This is Anthropic-style `cache_control` surface with no provider that can honor it. -- **`MessageSourceMap.agent`** (`{ kind: 'agent'; agentId: string }`, same file). Zero constructors, tests included. Its intended producer shipped without it: the subagent backends send the parent's prompt to the child with no `source`, so it logs as `{ kind: 'user' }`, and the generic envelope renderer interpolates `source.kind` without ever routing on it. The variant is pasted into [core.md](../../../core-data-structures/core.md). -- **`TurnTriggerMap.continuation`** (`packages/core/session/src/types.ts`). The loop structurally cannot emit it — continuation happens *within* a turn as further steps, never as a new turn — and it constructs only `message` and `injection` triggers. The only writer is one hand-built test fixture that needs an arbitrary non-message trigger (`packages/support/llm-replay/tests/llm-replay.spec.ts`); the only production trigger reader, the ACP bridge, filters on `kind === 'message'`. The variant is pasted into [session.md](../../../core-data-structures/session.md). - -## Proposal - -Delete `CacheHint` with its three `cache?` fields, the `agent` message-source variant, and the `continuation` turn-trigger variant. Switch the llm-replay fixture to an `injection` trigger (any non-`message` trigger serves its purpose). Update the type-equiv pastes in [core.md](../../../core-data-structures/core.md) and [session.md](../../../core-data-structures/session.md) (and `scripts/type-equiv.manifest.json` where block identity shifts) in the same change, and amend the [content-block vocabulary RFC](../../implemented/architecture/2026-06-11-content-block-vocabulary.md)'s consequence line naming cache hints as having a home, per [implemented/AGENTS.md](../../implemented/AGENTS.md). - -Each variant returns the day it gains a real producer, exactly as the maps are designed to grow: a caching feature re-adds `cache` together with the adapter that transmits it; subagent attribution re-adds `agent` together with the backend that stamps it and a consumer that routes on it; an auto-continue feature that genuinely starts new turns re-adds `continuation` with the plugin that emits it. - -## Why not keep them? - -The [content-block vocabulary RFC](../../implemented/architecture/2026-06-11-content-block-vocabulary.md) lists "cache hints … have a home" as a design consequence, and reserved slots do advertise intent. But an empty slot is contract surface every implementation and consumer must consider (must my adapter honor `cache`? must my renderer route `agent` sources?), and the sibling map's own JSDoc already rejects reservation-without-emitter — `refusal` and `max_turn_requests` are named as variants to add *when something first emits them*, not declared in advance. Holding already-declared dead variants to the same standard makes the vocabulary mean something: if it is in the map, something produces it. - -## Acceptance criteria - -- `rg` for `CacheHint`, the `agent` message-source spelling, and the `continuation` trigger spelling returns only this RFC. -- The core-data-structures pastes and the type-equiv manifest are in sync (`pnpm run doc-sync` green). -- The fixture asserts the same replay behavior with an `injection` trigger; the suite is green. - -## Risks - -None operational — nothing can construct these values today. The mirror-event removals (recorded in [the boundary-mirror RFC](../../implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) and [the stream-chunk RFC](../../implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md)) touch only transient `agent/*` events, never the durable vocabulary, so there is no collision. Elsewhere in the vocabulary the admission policy already holds: `rejected`, `prompt/blocked`, and `hook/invoked`/`hook/result` each have live producers — this RFC extends the same bar to the three variants that lack one. If the [image-block RFC](../../implemented/simplification/2026-07-04-drop-image-content-block.md) ships first, one of the three `cache?` fields leaves with it; the two proposals are independent and compose in either order. diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index b9a4c3b89b..27aaf580bc 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -91,7 +91,6 @@ export interface CreateSessionOptions { */ export interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } - continuation: { kind: 'continuation' } /** * An out-of-band context injection (`agent.inject()`) made while the agent * was idle. The loop wraps the injected `context/message` in a one-shot turn diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index fbfc7eb523..61d4769ffa 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -22,14 +22,10 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { CallId } from './brand.ts' -/** Cache hint attached to a content block (provider-interpreted). */ -export type CacheHint = 'ephemeral' - /** Plain text visible to the end user. */ export interface TextBlock { type: 'text' text: string - cache?: CacheHint } /** Reasoning / thinking content, distinct from visible text. */ @@ -54,7 +50,6 @@ export interface ToolResultBlock { toolCallId: CallId content: ContentBlock[] isError?: boolean - cache?: CacheHint } /** @@ -91,7 +86,6 @@ export interface Message { export interface MessageSourceMap { user: { kind: 'user' } plugin: { kind: 'plugin'; plugin: string } - agent: { kind: 'agent'; agentId: string } } export type MessageSource = MessageSourceMap[keyof MessageSourceMap] diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 2dd8357cc7..87cc62d165 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -118,7 +118,7 @@ describe('deriveReplayScript', () => { it('ignores non-assistant/chunk events', () => { let seq = 1 const events: SessionEvent[] = [ - { type: 'turn/start', seq: seq++, time: 0, data: { turn: 1, trigger: { kind: 'continuation' } } }, + { type: 'turn/start', seq: seq++, time: 0, data: { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } } }, ...TEXT_CHUNKS.map(c => chunkEvent(seq++, 1, 1, c)), { type: 'turn/end', seq: seq++, time: 0, data: { turn: 1, reason: { kind: 'completed' } } }, ]