Certified Agent Harness Engineer
Course sections

M6.4

Capability negotiation

766 words · about 5 min

Concept

Four providers, four capability sets. The parent asks for an output schema; the ACP child cannot do output schemas. There are exactly two possible behaviours, and only one of them is acceptable.

The unacceptable one is accept-then-ignore: take the request, drop the requirement, run anyway. The child returns unstructured text, the parent's schema-dependent branch reads undefined, and the failure surfaces three layers away from its cause as a null-pointer error in code that had every reason to believe the contract held.

The acceptable one is declare and refuse. Every provider states at construction time what it supports. Before dispatch, the orchestrator checks the request against the declaration and fails loudly if the provider cannot honour it.

This is the same principle as Module 5's "misconfiguration fails loud," applied to delegation. A capability that is silently degraded is worse than one that is absent, because absence is discoverable and degradation is not.

Mechanism: start-time capability declaration

Mechanism
interface SubagentCapabilities {
  readonly outputSchema: boolean
  readonly depthLimit: boolean
  readonly toolFilter: boolean
  readonly persona: boolean
}

Four booleans, fixed at provider construction, checked before dispatch. An unsupported request raises SubagentError('UNSUPPORTED_CAPABILITY') — a named, catchable, specific error rather than a downstream mystery.

The declared matrix in dsh is stark:

ProvideroutputSchemadepthLimittoolFilterpersonainheritsParentContext
fork✓✓✓✓true
spawn✓✓✓✓false
acp————false
codex————false
claude-code————false

Every out-of-process provider advertises nothing. That is not an oversight; it is honesty. You cannot impose your tool filter on someone else's agent, and pretending otherwise would be a security claim you cannot back.

Note what is not a capability. ContinuableCreateSpec { seed?: readonly SessionEvent[] } is described in the subsystem docs as data, never a capability — a seed is a value you pass, not a feature you must negotiate. Keeping the capability set small and boolean is what makes the pre-dispatch check trivially correct.

Worked example — the vanishing tool filter

Worked example

An orchestrator restricts an untrusted-content-processing child to read-only tools, per the Module 5 blast-radius argument, and routes it to whichever provider is configured. In one deployment that is spawn; in another, claude-code.

Under accept-then-ignore, the second deployment runs a child with the foreign agent's default tool set — which includes writes — while the orchestrator's logs record that a filter was requested. The security property is gone and the audit trail says it is present. That is the worst possible outcome, exactly as Module 5's "silent unconfined fallback" pitfall predicts.

Under declare-and-refuse, the second deployment fails at dispatch with UNSUPPORTED_CAPABILITY, and the operator must choose: route to a provider that supports the filter, or accept the risk explicitly.

Tradeoffs

Strict negotiation makes deployments brittle in a good way: configurations that would have silently degraded now refuse to start. The cost is real operational friction, and a temptation to add a "best effort" mode that reintroduces the original problem under a nicer name. Resist it. If you need graceful degradation, make it a caller decision — catch UNSUPPORTED_CAPABILITY and choose a fallback explicitly — never a provider decision made silently.

How `dsh` does it

How dsh does it

Beyond the matrix, dsh handles the depth-limit case with special care because it is a safety property. maxDepth on the delegation tool accepts a number (default 3, 0 forbidding delegation) or the string 'provider-managed'. The third value exists precisely for dsh-sdk: when the child is a full harness with its own recursion budget, claiming local enforcement would be a lie, so the config names the fact instead.

Depth itself lives in two places — SessionHeader.delegationDepth and AgentOptions.subagentDepth — with the greater value authoritative and a monotone floor on cold resume. Depth can never decrease across a restart, which closes the obvious attack: crash the process and resume at depth zero to escape the recursion budget.

How others differ

Most ecosystems negotiate capabilities at the protocol level rather than the delegation level. MCP negotiates during initialization; ACP performs an initialize handshake before newSession. What is less common is a second negotiation layer between the orchestrator and its provider abstraction, which is what the SubagentCapabilities record provides. Where that layer is missing, unsupported requests tend to be dropped by whichever adapter sits closest to the wire — silently, and per-adapter.


Check yourself

3 questions on M6.4

Start drill