Certified Agent Harness Engineer
Course sections

M1.6

Reading the capability seam

1,043 words · about 7 min

Concept

A capability seam is the boundary where model-agnostic core logic stops and vendor-specific code begins. Reading a seam correctly is the skill that lets you open an unfamiliar harness and predict what changes when you swap a provider. In dsh the term is precise — from docs/glossary.md and AGENTS.md, a seam comprises three roles:

  • Service Definition — owns the ctx.<key> and the vocabulary types. It is an abstract class or a concrete registry, deliberately never a TypeScript interface, because the definition must exist at runtime to be registered.
  • Service Provider — one or more implementations.
  • Consumer — the code that calls the service through the key.

The rule stated in AGENTS.md is: "A capability seam comprises Service Definition / Service Provider / Consumer roles. It is complete, never one role." Reviewing only the provider misses the seam.

Mechanism

For the model seam, docs/capability-seams.md gives the whole census in one row:

KeyOwnerImplementationsDirect consumers
ctx.llmpackages/llm/llmllm-deepseek, llm-pi-ai, llm-replayagent-loop, compaction-basic

Everything about the boundary follows from that row. The definition is LlmRuntime in packages/llm/llm/src/index.ts, a Service that owns the key:

export class LlmRuntime extends Service {
  constructor(ctx: Context) { super(ctx, 'llm') }
  registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>
}

The provider contract is one abstract class in the same file, and it has exactly one required method:

export abstract class LlmAdapter {
  providerInfo(provider: string): LlmProviderInfo
  listModels(_provider: string): Promise<readonly LlmModelInfo[]>
  resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise<LlmResolvedModelInfo>
  /** Stream one model call as raw chunks. The only required method. */
  abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>
}

That is the seam in one sentence: an adapter's entire obligation is to turn GenerateOptions into an AsyncIterable<StreamChunk>. The module docstring on packages/llm/llm/src/types.ts states the exclusivity — "Adapters alone translate provider wire messages" — and everything upstream (the loop, the log, the UI, compaction) is written against the neutral vocabulary.

Registration is an effect, per the AGENTS.md convention "Registrations are effects": registerAdapter returns a handle that is also its own disposer, so unloading a plugin withdraws its routes. The failure prevented is a stale registration surviving a hot reload and serving requests from dead code — which is also why registerAdapter throws DUPLICATE_ADAPTER all-or-nothing if any route is taken, rather than half-registering.

Worked example: what crosses, and what does not

Worked example

Read LlmRuntime's adapter dispatch and you can see the core doing work an adapter must never do:

  • Selecting the adapter. A private registration(provider) lookup throws NO_ADAPTER if none is registered. Routing is core, not vendor.

  • Turning throws into protocol. Adapter selection, dispatch, and iteration failures are converted into a terminal finish chunk:

    function adapterFailureChunk(error: unknown, signal?: AbortSignal): StreamChunk {
      const failure = normalizeLlmFailure(error)
      return {
        type: 'finish',
        reason: signal?.aborted || failure.code === 'ABORTED'
          ? { kind: 'aborted', failure }
          : { kind: 'error', failure },
      }
    }

    The consumer therefore only ever handles one shape — a stream that always ends in finish — instead of a mixture of streams and exceptions. That is the failure this design prevents: half the loop's error handling collapsing into the stream protocol and the other half into try/catch. Note the deliberate asymmetry documented on stream(): adapter failures become chunks, while "middleware, nested-call, cleanup, and consumer failures remain thrown" — a bug in your listener must not be disguised as a provider outage.

  • Interception. stream() does not call the adapter directly; it goes through ctx.waterfall(this, 'llm/stream', options, () => this.adapterStream(...)). Retry, replay, and routing plugins register on that waterfall and either call next() to reach the adapter or yield their own chunks. packages/llm/llm-retry is a real consumer of this hook. The seam is thus interceptable without being editable — new behaviour arrives as a plugin, per "Plugins, not loop changes".

  • Immutability of loop-built requests. The llm/stream JSDoc says a loop-built request "arrives deep-frozen (mutation throws): its content is a pure function of the session log … so listeners read it, never rewrite it." The failure prevented is a middleware silently mutating history so the transcript no longer reconstructs the request that was actually sent.

Note also where ToolSchema is declared. Its JSDoc in types.ts explains that it lives in dsh-llm and not in the tools package "because it is part of GenerateOptions". Seam placement follows who must agree on the type, not topical tidiness — the model request and the adapter must agree on tool-schema shape, so the type belongs to the model seam.

Tradeoffs

A narrow adapter contract (one stream method) makes adapters trivial to write and audit, and pushes complexity into the core, where an adapter cannot express something genuinely provider-specific without widening the shared vocabulary. dsh accepts that friction deliberately: widening the union is a reviewed change, whereas per-adapter escape hatches are invisible. The escape valve that exists — replayState on assistant messages, described in packages/llm/llm/src/message.ts as "lossless-JSON adapter state needed to replay the provider response" — is tightly constrained: LlmRuntime exposes it to a target adapter "only when that adapter instance currently owns both this historical provider and the target provider," so provider-private state cannot leak across a provider swap.

How others differ

Vercel's AI SDK draws a comparable seam with a versioned language-model provider interface and normalised stream parts, and has repeatedly had to revise that interface as capabilities grew — evidence that this seam is genuinely hard to get right, not a solved problem. Frameworks that pass raw provider responses through have no seam at all, and pay for it with vendor conditionals scattered through application code.

How to read a seam in an unfamiliar codebase

A repeatable procedure:

  1. Find the definition: search for the service key (ctx.llm) or the abstract base class. Read its exported types — that is the vocabulary.
  2. Find the providers: search for the registration call (registerAdapter, register, provide). Each hit is one implementation.
  3. Find the consumers: search for uses of the key. If a consumer imports anything from a provider package, the seam is leaking.
  4. Ask the swap question: if I registered a different provider, what else would have to change? The correct answer is "nothing". Anything else is your bug list.

Check yourself

4 questions on M1.6

Start drill