ADR-105: Typed user-input suspension (WAITING_FOR_INPUT)
- Status
-
Accepted
- Date
-
2026-07-22
- Authors
-
Netresearch DTT GmbH
Context
Human-in-the-loop so far means approval (ADR-084): a tool
opts into a verdict, the run suspends WAITING_FOR_APPROVAL, and approve()
continues it with approve/deny. But some tools need the human to supply
typed data, not a verdict — a target the model cannot know, a value only a
person can decide. The P1 roadmap's final queue-epic slice asks for exactly
this: a tool that suspends the run to collect schema-validated input and
resumes with it fed back into the tool's arguments.
Decision
A new suspend kind, routed by the persisted status discriminator:
WAITING_FOR_APPROVAL → approve(), WAITING_FOR_INPUT → ``submitInput()``.
The two never share a code path after the row is loaded, so the persisted state
needs no "kind" field.
- Tool signal. A tool opts in with the
RequiresInputInterfacemarker (the sibling ofRequiresApprovalInterface), declaring an input schema viagetInputSchema(). When the model calls an offered such tool, the loop throwsToolInputRequiredExceptioncarrying aSuspendedRunStatewhose newinputToolName/inputSchemafields name the target and its schema. The runtime's ladder catches it right after the approval arm (before the guardrail pair and the genericThrowable) and persists WAITING_FOR_INPUT. - Reuse. The status is stored in the existing
suspended_statecolumn (no new column), the guarded suspend/claim transitions mirror ADR-084 (suspendRunForInput/claimForResumeFromInput), the lease is cleared so the reaper (ADR-104) ignores a waiting run, and the MAX(sequence)+1 resume-position resolution is unchanged. - Divergence — validate before claim.
submitInput()validates the submission against the declared schema (via the existing structure-onlyJsonSchemaValidator, ADR-082) before probing or claiming the run. An invalid submission is rejected without consuming the claim, so the run stays WAITING_FOR_INPUT and the user can resubmit — a flow approve/deny has no analogue for. - Overlay. On resume, the human's validated values are overlaid onto the target call's arguments bounded to the schema-declared keys: the model's own values for those keys are stripped and only declared keys from the human are merged, so neither side can smuggle a value into the other's field.
Fail-closed rules
- A degenerate schema is corruption, never accept-all. An empty/shapeless
inputSchema(against whichvalidate()returns true for anything) is rejected at both the capture-time gate (LogicException) and the rehydrate gate (CorruptSuspendedStateException) — one shared predicate (InputSchema::isUsable()) is the single authority. - A tool may not be both approval- and input-gated. The approval-resume
path carries no input and would silently drop the mandatory data; the
combination is rejected at tool registration, and — defence in depth —
resume()refuses an input-requiring pending call rather than fail-open executing it. - An unstorable suspension fails closed as
SUSPEND_FAILED(as approval does): promising an input flow that cannot be resumed would strand the client. - Submitted values are untrusted content entering the model context; the submit entry point is admin-gated, which is the injection mitigation (structure-only validation does not sanitise content).
Consequences
AgentRunOutcomegainedAWAITING_INPUT;AgentEventKindgainedINPUT(payload{submittedBy}only, never the values, ADR-064);AgentRunStatus::WAITING_FOR_INPUTalready existed and needed no change. All are the documented minor-release growth path — consumers match with a default arm.AgentRuntimeInterfacegainedsubmitInput()(an interface method, not a new public service — the audited public-service count is unchanged);AgentRunRepositoryInterfacegainedsuspendRunForInput()andclaimForResumeFromInput();ToolLoopServiceInterfacegainedresumeWithInput().- Accepted limitations: validation is structure-only (no min/max/enum/pattern —
a tool needing those re-checks in
execute(), ascompleteStructureddoes); a single turn requesting two tool inputs is fail-closed-refused on the second, not collected; the schema is delivered in the suspend response, not viastatus()(which stripssuspended_state), so a client reloading mid-wait loses the form — matching the existing approvalpendingToolslimitation.