ImageBlock had no production producer and every consumer dropped it: the deepseek serializer skipped it, the pi-ai converter skipped it as unrepresentable, the ACP bridge neither advertises image prompt capability nor forwards image blocks, and compact-basic charged a flat 85-token estimate and rendered an [image] placeholder. A block constructed today would silently vanish from the wire — the vocabulary advertised a capability no path honors, the silent-data-loss shape the defensive patterns warn against. The only constructors were tests pinning the skip/estimate branches. Remove ImageBlock and its ContentBlockMap entry (its cache?: CacheHint field leaves with it; CacheHint itself and the other two cache? fields are out of scope). compact-basic loses its explicit image estimate and placeholder arms (the merge-extensible default arms absorb the case); the deepseek serializer, pi-ai converter, and ACP codec already handled image in their default arms, so only their image-naming comments change. The codec's inbound rejection of ACP-protocol image prompt content stays — that guards wire content a client can send regardless of our vocabulary. Tests that constructed harness image blocks to pin the removed branches are dropped (the 85-token estimate pin) or retargeted onto plugin-added block types / other non-text blocks, which the surviving default arms own. Docs, the type-equiv pastes, and the content-block vocabulary RFC's block list and multimodal-home consequence are updated in the same change; the RFC moves to implemented/ and the index is regenerated. A real multimodal feature reintroduces image via declaration merging together with the adapter mapping, ACP advertisement, and compaction pricing that honor it.
116 lines
5.0 KiB
TypeScript
116 lines
5.0 KiB
TypeScript
/**
|
|
* Pure translation between harness vocabulary and ACP wire types. No I/O, no
|
|
* Cordis context — every function here is total and unit-testable in isolation.
|
|
* Keeping the mapping pure is deliberate: the SDK rejects an unknown
|
|
* `stopReason`, so the {@link turnEndToStopReason} total function (with its
|
|
* exhaustive test over every `TurnEndReason` kind) is the guard that a turn
|
|
* always settles to a legal wire value.
|
|
*
|
|
* @module @deepseek-ai/dsh-acp/codec
|
|
*/
|
|
|
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
|
import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk'
|
|
|
|
/**
|
|
* Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum.
|
|
*
|
|
* The mapping is total over the kinds the loop actually produces today
|
|
* (`completed`/`aborted`/`error`/`disposed`/`max-tokens`/`rejected`).
|
|
* `TurnEndReason` is
|
|
* merge-extensible, so an unknown future kind falls through to `end_turn` —
|
|
* the safest default (the turn DID end; we just lack a more specific wire
|
|
* reason) — rather than throwing into the SDK, which would reject an unknown
|
|
* `stopReason` and break the prompt RPC. When a new kind gains a dedicated ACP
|
|
* reason (e.g. a future `refusal` → `refusal`), add an explicit case here.
|
|
*
|
|
* - `completed` → `end_turn` (the model chose to stop)
|
|
* - `max-tokens` → `max_tokens` (cut off at the output-token ceiling)
|
|
* - `aborted` → `cancelled` (a step abort or a queue-aware `agent.cancel()`, e.g. from `session/cancel`)
|
|
* - `error` → `end_turn` (defensive fallback only: the bridge REJECTS the
|
|
* `session/prompt` RPC on an error turn BEFORE calling this, so
|
|
* a client sees a JSON-RPC error, not a stop reason — see
|
|
* `rejectPrompt` in index.ts. This case keeps the function total
|
|
* for any non-bridge caller / property test.)
|
|
* - `disposed` → `cancelled` (the agent was torn down mid-turn — closest to a
|
|
* cancellation from the client's perspective)
|
|
* - `rejected` → `cancelled` (the prompt was blocked by an `agent/prompt-submit`
|
|
* hook before any step ran — ACP has no "rejected" reason, and a
|
|
* blocked prompt is, from the client's view, the prompt not being
|
|
* carried out; `cancelled` is the closest legal wire reason)
|
|
*/
|
|
export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
|
switch (reason.kind) {
|
|
case 'completed':
|
|
return 'end_turn'
|
|
case 'max-tokens':
|
|
return 'max_tokens'
|
|
case 'aborted':
|
|
return 'cancelled'
|
|
case 'disposed':
|
|
return 'cancelled'
|
|
case 'rejected':
|
|
return 'cancelled'
|
|
case 'error':
|
|
return 'end_turn'
|
|
// Merge-extensible: an unknown future TurnEndReason kind still has to
|
|
// produce a legal wire value (the SDK rejects unknown stopReason), so
|
|
// default to end_turn rather than assertNever. Add an explicit case when a
|
|
// new kind gains a dedicated ACP reason.
|
|
default:
|
|
return 'end_turn'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Translate a harness {@link ContentBlock} from a prompt into ACP content for
|
|
* replay, or `undefined` for block kinds the bridge does not surface to the
|
|
* client as message content. Today only `text` maps; `resource_link` is an
|
|
* ACP prompt-only input rendered into text by {@link acpPromptToText};
|
|
* `reasoning` is surfaced via `agent_thought_chunk`
|
|
* streaming rather than as a message block, and `tool-call`/`tool-result`
|
|
* are handled by the tool-call update path.
|
|
*/
|
|
export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | undefined {
|
|
switch (block.type) {
|
|
case 'text':
|
|
return { type: 'text', text: block.text }
|
|
// reasoning → streamed as agent_thought_chunk, not a message block
|
|
// tool-call / tool-result → the tool_call / tool_call_update path
|
|
// plugin-added block types → not surfaced
|
|
default:
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract plain text from an ACP prompt's content blocks. Text blocks are
|
|
* concatenated verbatim; resource links become explicit textual references so
|
|
* baseline ACP clients can point at files without the bridge silently dropping
|
|
* that context.
|
|
*/
|
|
export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
|
|
return prompt
|
|
.flatMap((block): string[] => {
|
|
switch (block.type) {
|
|
case 'text':
|
|
return [block.text]
|
|
case 'resource_link':
|
|
return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`]
|
|
default:
|
|
return []
|
|
}
|
|
})
|
|
.join('')
|
|
}
|
|
|
|
/**
|
|
* Whether an ACP prompt contains content the bridge cannot accept. Baseline ACP
|
|
* requires `text` and `resource_link`; richer inline payloads (`resource`,
|
|
* image, audio, …) are rejected rather than silently dropped.
|
|
*/
|
|
export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean {
|
|
return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link')
|
|
}
|