Elicitation for an ambiguous device target¶
Spike for #13945: one flow (an
ambiguous_device_target refusal) may ask a supervising client which device to use. Confirmations
(killDevice, uninstallApp, data wipe) and the other candidate flows are out of scope until the
measurement below says otherwise.
Contract¶
- A tool result that is the typed
ambiguous_device_targetrefusal with two or more distinct candidates, on a call that names nodeviceId, is intercepted by the sharedtools/callenvelope (installToolCallDispatcher). - If the client declared form elicitation, the server asks once with
elicitation/createwith a single-select enum:deviceId, oneoneOfentry per candidate, titled<deviceId> (<platform>). acceptnaming an offered device re-runs the call once with thatdeviceId. Anything else returns the original refusal byte for byte: decline, cancel, a device that was not offered, a failed ask, a cancelled call, or no answer withinAMBIGUOUS_DEVICE_ELICITATION_TIMEOUT_MS(20 s, on the injectedTimer).- The refusal wire contract (
test/fixtures/refusal-wire/ambiguous_device_target.json) does not change; the wrapper reads it, so every raise site (executionTarget,readTarget,ReadinessSelection) is covered without edits.
Capability and topology¶
- Legacy capability is read per connection from the server instance that owns the client’s
initialize(getClientCapabilities()), never cached.elicitation.form, or a bareelicitation: {}, counts; a URL-only declaration does not. - Legacy requests ask over a stateful client connection: the stdio proxy and direct stdio server. A legacy daemon HTTP request has no push channel; its proxy asks the client instead.
- Modern (2026-07-28) requests declare capabilities in their request envelope and ask through MRTR:
input_requiredembeds the device question asinputRequests.deviceChoiceand an SDK HMAC-SHA256requestState. A retry echoes that state alongsideinputResponses.deviceChoice. The versioned state binds the tool name, SHA-256 of arguments serialized with canonical key order (excluding only the top-leveldeviceId), and the offered device IDs. It expires after ten minutes, checked with the injected clock as well as the SDK’s wall-clock expiry. - The SDK verifies modern state before dispatch and exposes only the verified payload to the
wrapper. The selected device must belong to both the signed offered list and the backend’s
freshly computed candidates, and the tool and argument hash must match. Missing or mismatched
state, or a declined, cancelled, invalid or empty response map returns the original refusal
unchanged and never asks again. Tampered, malformed, expired or session-mismatched wire state
is rejected by the SDK with
-32602,Invalid or expired requestState, anddata.reason: "invalid_request_state", before the tool executes. - A lazy per-process 256-bit secret survives the daemon’s per-request server construction;
a daemon restart invalidates outstanding state. The SDK’s
bindtag uses the request header’s MCP session key through the shared session-key reader. State from a keyed caller cannot be used under a different or absent key. Requests without a session-key header share an unkeyed binding; callers must supply a stable key for caller isolation. The state is signed, not encrypted, and contains no secret or raw session key. Legacy elicitation is unchanged.
Switch and decline memory¶
AUTOMOBILE_ELICITATION=off(src/server/elicitationPolicy.ts, read per call) disables the question in every era: the legacy wrapper, the MRTR wrapper and the task path return the refusal without asking. The refusal is recorded with outcomedisabled.- A legacy connection remembers a
declineorcancelper candidate set (sorted, de-duplicated device ids) in aDeclineMemorycreated once perinstallToolCallDispatcher, i.e. per stateful client connection; the next identical ambiguity is recorded asrememberedand not asked.accept,timeout,invalidanderrorare not remembered, and a different set asks again. Modern requests are stateless and remember nothing.
Why it cannot stall an unattended call¶
Headless agents are most traffic and rarely declare elicitation, so they take the unchanged path. A
legacy harness that declares it but has nobody to answer is bounded by the timer, which aborts the request
(the SDK sends notifications/cancelled) and returns the refusal. The retry passes deviceId, so it
cannot ask twice. Modern requests return the question immediately without waiting on a timer; any
retry response map ends the question flow, and a valid selection runs the backend once more.
Measuring before extending¶
Extending to confirmations needs data on which clients declare elicitation and how asks end. Every
ambiguous-device refusal on a call without deviceId produces one ElicitationRecord
(src/server/elicitationOutcome.ts):
declared:none,bare(elicitation: {}),formorurl, read per connection (legacy) or from the request envelope (modern).era:legacypush ormodernMRTR/task.outcome:not_offered(client cannot be asked: the denominator for the rest),asked(modern question handed back; the answer arrives on a later request), thenaccept,decline,cancel,timeout(legacy only),invalid(accepted an unoffered device, malformed or missing answer) orerror(the ask itself failed).latencyMs: ask to answer on the injected timer, for legacy asks and modern answers; absent fornot_offeredandasked.
Every process logs one [elicitation] ... info line per record. The server composition root
(src/server/index.ts) also registers a recorder that pushes a push-only elicitation telemetry event;
the stdio proxy has no telemetry sink and logs only. The ElicitationRecorder is injectable
(ambiguousDeviceElicitation.recorder for the legacy path, an argument to withAmbiguousDeviceMrtr),
so tests assert records with a fake.
Real-client conformance¶
test/server/claudeCodeElicitation.integration.test.ts drives a real Claude Code (at least 2.1.311, named by
AUTOMOBILE_CLAUDE_BIN; skipped without one, and a claude on PATH is never spawned) with a loopback stand-in for the
Anthropic API against a stdio stub built on the production dispatcher. It runs in the advisory nightly
job claude-code-elicitation-conformance against a pinned binary and never touches a device. Measured
against 2.1.311: headless -p declares elicitation: {form, url} on initialize and answers the
legacy question with cancel, so the call ends with the typed refusal. A stub that serves the
modern era over stdio but does not answer server/discover is treated as a legacy peer: capabilities
arrive on initialize, no request envelope is sent, and the modern wrapper records not_offered. The test
branches on the envelope so it also covers Claude Code taking the 2026-07-28 path when it does.
MRTR limits (modern era)¶
- A modern tool call that runs as a task returns its task handle immediately. Inside the task’s
work, an ambiguity refusal pauses it in
input_required;tasks/getinlines the question asinputRequests.deviceChoice, and only the creating session’s owner key may answer throughtasks/update. The original request envelope must declare form elicitation, and the call must omitdeviceId. The same accepted-content schema validates the choice against the backend’s candidates before one retry. A declined, cancelled, invalid or empty answer fordeviceChoicecompletes with the original refusal and never asks again. An update omitting the key leaves the task waiting. Cancelling the task while it waits settles it as cancelled. - Task questions use the task’s input waiter rather than MRTR
requestStateor the legacy 20-second elicitation timeout. An unattended client leaves the task ininput_requireduntil answered, cancelled or expired by its task TTL. Running tasks currently advertisettlMs: null, so there is no automatic input deadline. Legacy task calls are unchanged.