M1.3
Anatomy of a request and a response
909 words · about 6 min
Concept
Strip away the SDK and one model call is a JSON body with four kinds of content — system prompt, message history, tool schemas, sampling parameters — and a response that arrives as a stream of deltas terminated by a stop reason. Learning to see any harness's request in these four parts is the fastest way to orient in unfamiliar code.
Mechanism: the request
Mechanismdsh names the whole request in one type. From
packages/llm/llm/src/types.ts:
export interface GenerateOptions {
provider: string
model: string
reasoningEffort?: ReasoningEffortId
messages: Message[]
system?: string
tools?: ToolSchema[]
temperature?: number
maxTokens?: number
stop?: string[]
signal?: AbortSignal
sessionId?: Branded<'SessionId'>
purpose?: 'compaction' | 'session-title'
}Read it as the four parts:
- System prompt —
system. Standing instructions: identity, house rules, environment facts, project instructions. It is not privileged in any cryptographic sense; it is just tokens the model has been trained to weight heavily. Module 5 returns to why that matters for injection. - Message history —
messages. The alternating record of user input, assistant replies, and tool results. Note that in this vocabulary a tool result is a user-role message:ToolResultMessageinpackages/llm/llm/src/message.tsisrole: 'user'with a singletool-resultblock and asource: { kind: 'tool', callId }. Tools speak to the model in the user's voice, which is exactly why untrusted tool output is a prompt-injection vector. - Tool schemas —
tools. Each is{ name, description, parameters }whereparametersis a JSON Schema object. This is the model's entire knowledge of what it can do; the description is functional documentation, not commentary. - Sampling parameters —
temperature,maxTokens,stop, plus routing (provider,model) andreasoningEffort.
signal is worth a separate note: it is the live cancellation channel, and it is
the one field dsh deliberately refuses to freeze. From
packages/llm/llm/src/call-config.ts, deepFreeze skips AbortSignal objects
"because they are the request's live cancellation channel and freezing them
breaks abort." The failure prevented is an uninterruptible agent.
Mechanism: the response
MechanismResponses stream. dsh normalises every provider's stream into one discriminated
union (packages/llm/llm/src/types.ts):
export type StreamChunk =
| { type: 'block-start'; index: number; blockType: ContentBlockType }
| { type: 'text-delta'; index: number; text: string }
| { type: 'reasoning-delta'; index: number; text: string }
| { type: 'tool-call-delta'; index: number; id: CallId; name?: string; argumentsDelta: string }
| { type: 'block-end'; index: number; block: ContentBlock }
| { type: 'usage'; usage: TokenUsage }
| { type: 'finish'; reason: FinishReason; replayState?: ReplayEnvelope }Three properties matter. First, content arrives as indexed blocks, so a reply
can interleave reasoning, prose, and several tool calls. Second, tool-call
arguments arrive as argumentsDelta — fragments of a JSON string, not a
parsed object. A harness must accumulate them and parse only at block-end;
parsing early yields syntax errors on every chunk. Third, the stream ends with
exactly one finish.
The stop reason is the loop's control signal. dsh enumerates it as a map:
export interface FinishReasonMap {
'stop': { kind: 'stop' }
'tool-calls': { kind: 'tool-calls' }
'max-tokens': { kind: 'max-tokens' }
'aborted': { kind: 'aborted'; failure: LlmFailure }
'error': { kind: 'error'; failure: LlmFailure }
}Each demands different behaviour: stop ends the turn; tool-calls means run
the tools and go around again; max-tokens means the reply was truncated
mid-thought and continuing naively will append a half-written function to your
file; aborted and error carry a structured failure. A harness that treats
max-tokens as stop will silently corrupt files — that is the failure this
enumeration exists to prevent.
Worked example
The adapter is where a provider's vocabulary becomes this one. From
packages/llm/llm-deepseek/src/translate.ts:
export function mapFinishReason(reason: string): FinishReason {
switch (reason) {
case 'stop': return { kind: 'stop' }
case 'tool_calls': return { kind: 'tool-calls' }
case 'length': return { kind: 'max-tokens' }
default:
// content_filter, insufficient_system_resource, future additions.
return {
kind: 'error',
failure: { message: `model stopped: ${reason}`, code: reason.toUpperCase() },
}
}
}Note the default branch. An unrecognised wire value — content_filter, or
something the provider ships next quarter — becomes an explicit error rather than
being dropped or misread as a normal stop. Unknown input becomes a named
failure. That is the entire discipline of provider drift in five lines.
Tradeoffs
Normalising every provider into one union costs you provider-specific expressiveness: a feature only one vendor offers has nowhere to live until the union grows. The alternative — passing raw provider responses through the loop — is cheaper on day one and turns every consumer into a vendor-conditional mess by month six.
How `dsh` does it
How dsh does itThe union is extended by declaration merging rather than by editing a closed
enum. FinishReasonMap, ContentBlockMap, and MessageSourceMap are interfaces
plugins can merge new keys into, with the union derived from the map's values.
The rule that makes this safe is stated in AGENTS.md: "Switch on discriminant
tags. Closed unions end in assertNever." Extensible unions must instead fall
through unknown cases gracefully — MessageSource in
packages/llm/llm/src/message.ts is documented as "switch on kind and fall
through unknowns (merge-extensible)."
How others differ
Anthropic's API streams content-block start/delta/stop events with a
JSON-fragment delta for tool arguments — structurally near-identical to the union
above, which is why a single adapter shape works for both. OpenAI-compatible
endpoints stream choices[].delta with a tool_calls array carrying an index,
and place finish_reason on the final choice. The concepts map; the wire formats
do not, which is exactly the job of an adapter.
Check yourself
5 questions on M1.3