Skip to content

feat(acp): agent-directed user questions via ACP form elicitation - #14

Merged
cursor[bot] merged 4 commits into
mainfrom
devin/1785563025-acp-user-input
Aug 1, 2026
Merged

feat(acp): agent-directed user questions via ACP form elicitation#14
cursor[bot] merged 4 commits into
mainfrom
devin/1785563025-acp-user-input

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 1, 2026

Copy link
Copy Markdown

Summary

Agents can now ask the human a question mid-turn and block on the answer. Today nothing of the sort reaches Buzz: we never advertise clientCapabilities.elicitation.form, so claude-agent-acp puts AskUserQuestion straight into disallowedTools, and if a question did arrive elicitation/create would fall into the unknown-method arm and get -32601.

Permission handling (session/request_permission) is untouched — it stays auto-approved. This is a separate interaction category, deliberately modelled after ADE's approval vs userInput split.

Flow:

agent → elicitation/create(requestedSchema)
harness → normalize schema into UserInputQuestion[] with Crew-owned ids (q0, q1, …)
       → publish KIND_AGENT_USER_INPUT_REQUESTED (46040, durable, h-scoped to the channel)
       → park the turn (both clocks suspended), keep draining stdout
owner  → KIND_AGENT_USER_INPUT_ANSWER (46041, e-tagged to the request)
harness → rebuild content with the ENGINE's field keys
       → {"action":"accept","content":{…}}   (or "decline" on skip, "cancel" on turn end)

Points worth reviewing rather than reading off the diff:

  • Clients never see engine keys. The schema's field names (question_0, question_0_custom, Codex's ids) and its const wire values are kept in an internal FieldMapping and re-applied on the way back; clients only ever exchange q0-style ids and display labels. An answer matching a declared option (by value or label) goes to the native field; only an unmatched value goes to the *_custom key. Same rule the permission handler already follows for optionId.
  • Both deadlines are suspended while a question is outstanding, then resume with fresh budgets. Otherwise the 15-minute idle timeout / 2-hour cap kills the turn while the human is asleep. Per the agreed design there is no question-specific timeout.
  • The answer is awaited inside the read loop's select!, not inline — so cancel, steer and further session/update frames keep being processed, and a pending question can actually be cancelled. cancel_with_cleanup answers the outstanding JSON-RPC id exactly once, mirroring pending_permission_id.
  • The h tag is the NIP-29 channel, not the conversation key. batch.channel_id is the sha256-derived conversation/queue id; publishing under it made the relay reject the event as restricted: not a channel member in every non-DM channel.
  • Answers ride a dedicated REQ (kind 46041, no #p constraint — CLI answers carry no p tag) and are consumed by QuestionRuntime only, so an answer never starts a new prompt turn.
  • Only the agent owner (or a verified same-owner sibling) can answer, reusing is_owner_or_sibling; first valid answer wins, later and non-owner answers are dropped.
  • Kinds are 46040/46041, not 46013/46014 — the latter would have landed inside is_workflow_execution_kind's 46001..=46012 range, which is a range check that future workflow kinds would silently extend over ours.
  • Unanswered optional fields are omitted rather than failing the whole form; a missing required field (Codex) cancels.
  • buzz user-input list|answer exists because it is the only way to exercise the flow until the desktop card lands; pending state is derived client-side from request/answer events (no durable pending projection yet), counting only answers the caller authored.

Not in this PR: desktop/mobile question cards, chat-reply fallback, durable pending-interaction state with restart reconciliation, and Goose's _goose/unstable/elicitation/* variant.

Related issue

None found.

Testing

Unit: schema normalization (single/multi-select, freeform, unsupported → cancel), option-vs-custom answer routing, natural question ordering with ≥11 fields, optional vs required fields, engine-key reconstruction, owner-only + first-answer-wins routing, exactly-one cancel response when a turn is cancelled with a question pending, and — with paused Tokio time — that neither the idle nor the hard deadline fires during a long pending window. Plus subscription-shape tests for the default Mentions filter and the answer REQ.

Real-process E2E against a local relay (Postgres+Redis) with a real buzz-acp harness driving an ACP-speaking fake agent, in a normal stream channel: question published and listed as pending, buzz user-input answer '{"q0":"production","q1":["lint","tests"]}' → the agent receives exactly one {"action":"accept","content":{"question_0":"production","question_1":["lint","tests"]}}, and no second turn is triggered. Also verified live: a 150 s pending question survives --idle-timeout 20 --max-turn-duration 40 (control run with a silent agent times out at 20 s, proving the timers were armed), cancel-while-pending emits exactly one cancel, non-owner and late answers are ignored, and BUZZ_ACP_NO_USER_INPUT=true advertises "elicitation":{"form":false} and cancels any question. Full evidence in the test-results comment.

The mobile Flutter CI stage was not run locally (hangs in this environment); no Dart files change here. No UI change.

Link to Devin session: https://app.devin.ai/sessions/c69bb63719224c65964381e6b09210dc
Requested by: @oscarlehuu

devin-ai-integration Bot and others added 3 commits August 1, 2026 06:02
Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Oscar Le <oscar.lehuu@gmail.com>
Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Oscar Le <oscar.lehuu@gmail.com>
Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Oscar Le <oscar.lehuu@gmail.com>
@oscarlehuu oscarlehuu self-assigned this Aug 1, 2026
@devin-ai-integration

Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration

Copy link
Copy Markdown
Author

Runtime E2E test results — ACP user input (elicitation/create)

Tested with a real buzz-acp process against a local relay, driving a fake ACP agent that speaks the protocol (Claude-shaped requestedSchema: question_0 oneOf + question_0_custom + question_1 array items.anyOf, required:["question_0"]). Evidence below is actual JSON-RPC frames and relay logs.

4 blocking problems — the flow does not work end-to-end in the configuration this PR describes. Fixes are in progress on this branch.

1. Request event carries the conversation UUID as its h tag → relay rejects it (normal channels)

pool.rs L1393-99 passes batch.channel_id to set_user_input_context, but that is the queue/conversation key (conversation::id_for_event = sha256("buzz-acp-conversation-v1" || channel || root_event_id)), not the NIP-29 channel.

DEBUG buzz_acp::relay: OK for event 19707971…: accepted=false
      message=restricted: not a channel member          (kind 46040)
$ buzz user-input list --channel b39af3d6-…      -> []
h tag used = fa81016d-bc35-4559-0d2d-bb3139dbe736
select count(*) from channels where id='fa81016d-…'  -> 0

The agent is a member and the channel is open — its kind-9/kind-7 events are accepted. 60 s later (idle 20 s, cap 40 s) there is no timeout and no cancel frame: the turn is parked forever and the human never sees the question. DMs are unaffected (there conversation id == channel id), which is how the rest of these tests were possible.

2. The harness never subscribes to kind 46041, so answers are never delivered

config.rs::resolve_channel_filters / resolve_dynamic_channel_filter build the REQ kinds for the default Mentions mode as [KIND_STREAM_MESSAGE, KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_STREAM_REMINDER]KIND_AGENT_USER_INPUT_ANSWER was only added to the rule list in lib.rs. The default REQ also adds #p=<agent>, and CLI answers have no p tag.

$ buzz user-input answer …  -> {"accepted":true,"event_id":"4eb200e5…"}
relay: {"message":"Fan-out","event_id":"4eb200e5…","match_count":0}
agent frame log: 0 responses, turn still parked

Everything below required BUZZ_ACP_KINDS=9,46041 BUZZ_ACP_NO_MENTION_FILTER=true. That workaround also makes each answer event start a new prompt turn.

3. The documented answer form produces the wrong engine key
--answers '{"q0":"production","q1":["lint","tests"]}'
RECV {"id":"elicit-1","result":{"action":"accept",
      "content":{"question_0_custom":"production","question_1":["lint","tests"]}}}

Required question_0 is missing; a selected option is sent as a freeform custom answer. reconstruct_content routes every Text answer to the *_custom key without checking the option list. Structured input works:

--answers '{"q0":{"selected":"production"},"q1":{"selected":["lint"]}}'
RECV {"id":"elicit-1","result":{"action":"accept",
      "content":{"question_0":"production","question_1":["lint"]}}}
4. buzz user-input list hides a question after a non-owner answer

cmd_list drops a request as soon as any 46041 e-tags it, without checking the author. The harness correctly ignores the non-owner answer (WARN ignoring non-owner user-input answer), so the question stays pending — but the owner's list goes from 1 to 0 and they can no longer answer it.

What does work (passed)
  • Schema normalization: q0/q1 ids, option value/label/description, multi_select, allow_custom_answer, channel-scoped h tag.
  • Deadlines suspended while pending (real process, --idle-timeout 20 --max-turn-duration 40): question outstanding 150 s → zero timeouts, zero cancel frames, still pending; then answered → accept + stopReason: end_turn. Control run with a silent agent timed out at 20 s (idle timeout (20s) — no agent activity), proving the timers were live.
  • Cancel while pending: !cancel from the owner → exactly one {"action":"cancel"}, then session/cancel, outcome="cancelled", nothing hung.
  • Authorization at the harness: non-owner answer ignored; a second/late answer to an answered request ignored (still exactly one response frame); a non-member is additionally blocked by the relay.
  • Kill switch (BUZZ_ACP_NO_USER_INPUT=true): initialize advertises "elicitation":{"form":false}, elicitation/create{"action":"cancel"}, no 46040 published.
  • CLI sanity: [] on an empty channel; rejects bad request id, non-JSON answers, non-object answers, bad channel UUID (all exit 1 with clear messages).
  • Nit: request content has "request_id":"\"elicit-1\"" (double-encoded JSON-RPC id).

Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Oscar Le <oscar.lehuu@gmail.com>
@cursor
cursor Bot merged commit ec87840 into main Aug 1, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant