Certified Agent Harness Engineer
Course sections

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

Mechanism

dsh 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: ToolResultMessage in packages/llm/llm/src/message.ts is role: 'user' with a single tool-result block and a source: { 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 } where parameters is 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) and reasoningEffort.

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

Mechanism

Responses 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 it

The 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

Start drill