Skip to content

UEP: requires_input — a task can end by asking the user, and the answer resumes it #476

Open

Description

@phanisarman

Problem

  • An agent working on a task often needs one answer from the user: a choice, a missing value, a confirmation.
  • UHP has no way to express this. A task ends completed, failed, incomplete or cancelled (lifecycle.md §state diagram), and the question is buried in the final text.
  • A client can't tell "done" from "waiting for you", and can't route the answer back in a structured way.
  • Should HarnessRouter support persistent harness runtimes instead of spawning a CLI process per turn? #25 asks whether persistent runtimes are needed for this kind of interaction. This proposal works with the existing process-per-turn model.

Proposal

  1. New terminal status requires_input. The task stopped because the harness asked the user something.
  2. New output item user_input_request:
    {"type": "user_input_request", "id": "uir_…", "question": "…", "options": ["…"], "expires_at": 1760000000}
    options and expires_at are optional.
  3. Answering is a normal continuation:
    POST /v1/responses
    {"previous_response_id": "resp_…", "input": [{"type": "user_input_response", "request_id": "uir_…", "answer": "…"}]}
    
    This needs no new route. The server resumes the session (hydrate + native resume, as it already does) and delivers the answer as the tool result, or as the next user message for harnesses without a question tool.
  4. Harness mapping:
    • harnesses with a native question tool map it directly;
    • others get a server-provided MCP tool (ask_user) through the existing MCP/plugin wiring;
    • a server that offers neither for a harness says so in /v1/uhp capabilities.
  5. Errors:
    • answer to an unknown request → 404;
    • second answer → 409;
    • answer after expires_at → 410;
    • any other input while requires_input is pending → treated as an answer-less continuation, reported in metadata.

Compatibility

  • Additive. Clients that don't know requires_input see a terminal non-completed status and the question text in output.
  • Servers that don't implement it never emit it.
  • Because the question ends the turn, there is no long-lived process, and the existing checkpoint/hydrate path covers waits of any length.

Alternatives

Activity

  1. jpelaez-23blocks commented on Oct 17, 2026

    @jpelaez-23blocks

    Related proposal: #479 (filed after this one) covers the same problem — a task that needs an answer from the user — with a different design on one axis: pause vs. end.

    #476 ends the task with terminal requires_input, and answering is a normal continuation. #479 pauses it with non-terminal awaiting_input, answers via POST /v1/responses/{id}/input, and pauses the task's timeout/step budgets during the hold.

    The pause model exists for live-loop harnesses: in Operario (a vision/VNC control loop), the hold happens mid-task — ambiguity disambiguation, credential gates — and the harness stays live. Splitting the hold into a task boundary would break the loop's continuity and its budget accounting. The end model fits process-per-turn runtimes better, and #476's MCP ask_user fallback is a good answer for harnesses without a native question tool.

    The two proposals agree on the important parts: typed questions, redaction for secrets, timeout/expired semantics, 409/410 handling. They differ on whether the wait is a task boundary or a mid-task state — which may come down to what the runtime supports. Happy to converge if maintainers prefer one shape, or for the spec to support both (pause where the runtime stays live, end where it doesn't).

  2. richard-epsilla commented on Oct 18, 2026

    @richard-epsilla
    Collaborator

    Thank you, @phanisarman, and @jpelaez-23blocks for linking the two. Our assessment: one proposal, in the end-the-turn shape this issue proposed first. Accepted in principle (GOVERNANCE), with the changes below. We asked #479 for this same shape on 10-18 without linking it here. The two now agree on nearly everything, so the result takes its shape from #476 and its typed request from #479.

    Where the two meet

    Changes to this proposal

    1. No new status. The turn ends incomplete with incomplete_details: {"reason": "input_required"}, not requires_input.
      • incomplete is already terminal and keeps partial output, and a client already reads it as "usually worth continuing" (Lifecycle §3). A client that switches on today's five statuses keeps working, and Responses SDKs parse it.
      • This widens incomplete from "a budget stopped it" to "stopped short, with a reason". So the PR amends Lifecycle §3 and defines incomplete_details, which the response example in Tasks shows but the field table does not.
    2. The request is an output item, and the answer is an input item. This is the pattern function_call and function_call_output already follow. It streams through the existing output item events, so no new event type is needed:
      {"type": "input_request", "id": "inreq_…", "kind": "choice", "prompt": "Which invoice?",
       "options": [{"id": "left", "label": "Top toolbar"}, {"id": "right", "label": "Sidebar"}],
       "expires_at": 1760000000}
      {"previous_response_id": "resp_…",
       "input": [{"type": "input_response", "input_request_id": "inreq_…", "answer": "left"}]}
      The turn's last message also carries the question as text, because a client that renders only message items is a valid client (Tasks §3.1).
    3. The answer arrives as the next user message, not as the tool result. Once the turn has ended, no tool call can still be open. So ask_user returns at once ("asked; your turn ends here"), the runner ends the turn, and the follow-up resumes the session with the answer as its message. A base that can natively resume a pending tool call may deliver the answer there instead; that is an implementation choice, not the contract.
    4. Errors, one code per remedy:
      • 409 input_not_pending: the response has no pending request with that id. It is unknown, it was already answered, or the session has had another turn since. Not 404: the response exists, and in UHP a 404 says the addressed object does not.
      • 410 input_request_expired: the answer came after expires_at. The field stays optional. Nothing runs during the wait, so this is a check at answer time.
      • 422 invalid_input: the answer does not fit kind or options. The request stays pending.
    5. Other input while a request is pending is a normal turn that voids the request, as you proposed, and the response names the voided request in metadata. (UEP: requires_input — a task can end by asking the user, and the answer resumes it #476)
    6. A secret answer never enters the conversation. It goes to the server's secret store, scoped to the session, and the harness gets a reference to it, as referenced secrets work today. (UEP: First-class input requests — input_required event, awaiting_input status, and POST /v1/responses/{id}/input #479)

    How the two issues split

    The PR follows GOVERNANCE: spec, schema, reference implementation, conformance test and changelog, together.

    • Reference implementation: one base end to end is enough, for example Claude Code with the server's ask_user tool.
    • Conformance:
      1. A question ends the turn incomplete with reason input_required and an input_request item.
      2. An answer resumes the session, and the turn completes.
      3. A stale or already-answered id returns 409 input_not_pending.
      4. An option id outside options returns 422 invalid_input, and the request stays pending.
      5. A secret answer appears nowhere in the stream, the stored record or GET /v1/responses/{id}.

    @phanisarman, @jpelaez-23blocks: if this works for you both, please say here who is writing the PR, so only one gets written, and credit the other as co-author. Questions are welcome here first.

  3. added
    uepUHP Enhancement Proposal (protocol/GOVERNANCE.md)
    on Oct 18, 2026
  4. Jahanzeb-git commented on Oct 31, 2026

    @Jahanzeb-git
    Contributor

    Scope for #476, in the end-the-turn shape the assessment settled.

    What breaks today. The question already arrives and we throw it away.
    runner/server.py:14531 answers codex's item/tool/requestUserInput with
    {"answers": {}}, and runner/tests/test_codex_requests.py:52 locks that in.
    On the other base, runner/cheetahclaws_driver.py:68 removes AskUserQuestion
    from the tool list, with the reason written above it: "a question goes into the
    answer instead, where the person reads it". So the question lands in prose and
    the turn reports completed. A client cannot tell "done" from "waiting for
    you", and no client can route an answer back.

    Shape. Four steps, no new route and no new event type.

    1. ask_user is served by the runner through the MCP wiring plugin tools already
      use. It returns a structured result — {"status": "input_required"}, no prose
      the model has to interpret — and the runner ends the turn at that point,
      emitting the input_request item with incomplete and
      incomplete_details.reason = "input_required". The turn ends on the runner's
      decision, not on the model's cooperation. The last message carries the
      question as text as well, for clients that render only message items.
    2. The pending request is stored with the response that asked, not in the process.
    3. The answer arrives as input_response on a normal previous_response_id
      follow-up. Three refusals before anything runs: 409 input_not_pending,
      410 input_request_expired, 422 invalid_input. 422 leaves the request
      pending; any other input voids it and the response's metadata names it.
    4. On accept the runner opens the next turn on the same thread
      (thread/resume with the same id — runner/server.py:14568), and the answer
      is that turn's user message, carrying the question back with it. Nothing holds
      a process open in between.

    One thing I would verify first. Terminating at the tool result — rather than
    letting the agent run on — is the right call: a wait can last days, and anything
    the agent does in that window is a side effect that cannot be discarded
    afterwards. What that costs is resuming a session whose process was terminated
    rather than one that exited on its own. Mature CLIs write their history as they
    go, so I expect resume to work here; I would confirm it on the reference base
    before relying on it. The resume message carries the question together with the
    answer, so the turn does not depend on the ask surviving in the transcript either
    way.

    Where the request lives. gateway/control_store.py is the right home, for the
    same reason you fixed four things in #613: the window. The request goes on
    resp::{org}::{rid} and is written in the same conditional replace that marks the
    response terminal (control_store.py:393). Written after, there is a moment where
    a client reads incomplete with input_required and finds no request behind it —
    and on Cloud a redeploy between the two writes loses the question for good.
    Consuming the answer is a conditional replace on the same ETag, so two replicas
    holding the same answer produce one turn and one 409, not two turns. expires_at
    needs no sweeper: nothing runs during the wait, so it is a comparison at answer
    time.

    The question text belongs to the response the client reads. The store keeps only
    what an answer is checked against: request id, kind, option ids, expiry.

    Commits — one PR, five commits, in the order you can review them:

    1. spec and schema: Tasks §3.1 for the item, §2 for the answer,
      incomplete_details in the §3 table it is missing from, Lifecycle §3 and the
      capability row, errors, schema.
    2. gateway: the item, the capability, the store, the three refusals.
    3. runner: ask_user and the turn end.
    4. conformance.
    5. changelog and suite bump.

    Tests, one per clause — F-11 to F-15, each skipping when the server does not
    claim input_requests, the way F-10 skips at checks.py:1362:

    1. a question ends the turn incomplete with reason input_required and an
      input_request item
    2. an answer resumes the thread and the turn completes
    3. an unknown or already-answered id returns 409 input_not_pending
    4. an option id outside options returns 422 invalid_input, request still
      pending
    5. a secret answer appears in no stream event, no stored record, and not in
      GET /v1/responses/{id}

    What I'd leave out. Codex's native requestUserInput: it already reaches
    _codex_request_answer and maps onto the same item cleanly, but it is a second
    channel, and one channel that works beats two that half-work. The #479 hold —
    awaiting_input, paused budgets, a live sandbox. Per-base integrations. A UI to
    answer in.

    The number. If you want it built, say so and I'll send you a number for it —
    I'd rather that be between us than in a thread other contributors read. If the
    split isn't right, tell me what to cut and I'll re-scope around it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    uepUHP Enhancement Proposal (protocol/GOVERNANCE.md)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions