Skip to content

support structured agent questions via ACP forms - #1

Draft
lnittman wants to merge 6 commits into
mainfrom
feat/acp-form-elicitation
Draft

support structured agent questions via ACP forms#1
lnittman wants to merge 6 commits into
mainfrom
feat/acp-form-elicitation

Conversation

@lnittman

@lnittman lnittman commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Important

This is a scoped reference implementation in Luke's fork, not a pull request
against block/berd. Berd's contribution policy accepts outside participation
through issues; upstream product/design discussion is
block/berd#114.

End-to-end managed-provider proof depends on the Goose forwarding work proposed
in aaif-goose/goose#11346.
The live captures used an exploratory dirty Goose checkout; this branch does
not vendor or pin those backend changes and is not claiming clean-backend
reproducibility before that prerequisite lands.

Summary

Add ACP form elicitation to Berd's existing chat interaction layer so an agent
can pause for structured human judgment without a prose menu. The implementation
supports single choice, true multi-select schemas, booleans, text/numbers,
typed custom Other answers, direct multi-question navigation, validation,
explicit decline/cancel, accessible focus, background attention, and honest
restart recovery.

There is deliberately no Interviews widget or Home node. Rich media, Mermaid,
arbitrary model-authored UI, and Cursor's custom question extension remain out
of scope for this slice.

Berd asking a two-question structured form in the composer area: a question stepper, single-select options with descriptions, an optional custom answer, a decline action, and the sidebar attention badge on the backgrounded session

Architecture

managed ACP provider
  -> nested session/elicitation/create
  -> goose serve forwarding prerequisite (#11346)
  -> Berd ACP client handler
  -> per-session elicitation store
  -> composer-area form + sidebar attention
  -> typed response back to the originating request
  • Berd advertises ACP form capability and forwards the request's AbortSignal
    through the vendored Goose SDK wrapper.
  • Explicit JSON-RPC cancellation removes only the matching live responder;
    connection replacement detaches the form so its draft is not destroyed.
  • Pending questions are keyed and queued per session and persist navigation and
    answers; a credential-marked field is unsupported rather than rendered, so no
    secret value is ever held or serialized.
  • Shared _askUserQuestionCustomAnswer metadata is preferred, with the current
    Codex adapter marker retained as a compatibility input.
  • Detached questions remain editable and can continue as one normal message.
    Delivery is single-winner: the answer is claimed before it is sent, and a
    responder that reattaches after dispatch is settled without being accepted,
    so the backend cannot see both a message and a response.
  • Multi-question hierarchy uses Berd's existing panel, button, radio, checkbox,
    input, typography, status-pill, and sidebar conventions.

Provider truth

  • Claude Code: full structured flow proven, including true multi-select,
    custom answers, multi-question navigation, and same-turn continuation.
  • Codex: structured single choice, text, and custom answers proven. Its
    current ACP adapter serializes question answers through string oneOf, so
    true multi-select intent is lost before Berd receives the schema.
  • Cursor: not structured on Berd's current Goose route. Goose launches the
    direct print/JSON provider, which does not expose client tools. Cursor's
    cursor/ask_question extension needs translation at a general ACP adapter
    boundary; prose is a fallback, not parity.

Interaction and safety details

  • question progress and answered-state navigation for multi-question forms;
  • real question promoted above bridge boilerplate while preserving unfamiliar
    provider messages as muted context;
  • native required/min/max/pattern/array validation and bounded multi-select;
  • credential-marked fields are unsupported rather than rendered as password
    inputs, since ACP form mode is specified for non-sensitive data;
  • focus moves into the form and returns to the prior composer target on exit;
  • restart recovery says whether the agent is still waiting or the answer will go
    as a new message, rather than pretending to replay a dead RPC;
  • a question is shown only by the chat that asked it, while that chat is on
    screen; a live question always takes presentation priority over a recovered
    draft, so a draft can never hide a request an agent is blocked on;
  • a security confirmation owns the composer slot alone while it is up, and a
    question waits behind it;
  • an agent-supplied pattern is treated as unevaluable rather than executed. A
    native regex cannot be interrupted, so running one is an unbounded cost taken
    on the sender's behalf; field, option, and byte counts are bounded instead;
  • a property type Berd does not understand is preserved and shown as
    unsupported rather than rendered as a control that misdescribes it;
  • an unsupported mode or scope returns invalid params rather than a synthetic
    user cancel, so a client limitation is never mistaken for a decision;
  • a live question is correlated by its JSON-RPC id, so two identical questions
    asked at once stay distinct;
  • answers are claimed before they are sent as an ordinary message, so a second
    send or a discard cannot race it;
  • saved drafts are cleared on logout and workspace switch, since session ids are
    not unique across accounts;
  • no provider-specific rendering branches and no parsing of prose into trusted
    controls.

Verification

  • focused suites: 3,608 passed, 1 skipped across 257 files (elicitation,
    transport, chat, sessions, app shell)
  • just check, Tauri checks, and warnings-denied Clippy green

A few timing-sensitive suites outside this change — composer mention search and
app-shell focus — flake when a dev backend competes for CPU. They pass in
isolation and fail identically with this branch stashed, so they are noted here
rather than counted. The full Vitest aggregate is ~6,785 tests and was
reproducible with the backend stopped; this head has not been re-measured under
those conditions.

Known limitations

  • Persistence identity fails open. Account and workspace resolution fall
    back through several candidates, and an unresolved provider becomes
    unknown, so distinct security contexts can collapse into one namespace. It
    should stay memory-only when identity is not verifiable.
  • Persistence recovery is all-or-nothing. One malformed record discards the
    whole store rather than just that record, and schema traversal is recursive
    with no depth bound.
  • Recovered continuation is not bound to its origin. It resolves the
    session's current execution target at send time, so changing provider between
    a question and its answer sends the answer to a different provider.
  • Accepted response size is unbounded. The request schema is capped, but
    user-entered content is not.
  • Numeric fields do not preserve intermediate states like -, 0. or 1e;
    email/uri/date formats are unvalidated; length uses trimmed UTF-16
    units rather than code points; invalid fields disable the button without a
    field-level explanation.
  • elicitationStore.ts and ElicitationField.tsx have grown past the point
    where they should be single modules.

Current-work coordination

The final diff is limited to ACP capability/transport wiring, the elicitation
feature, ChatView placement, and the existing sidebar status slot.

The closest active upstream branches remain block#19 (provider/model lifecycle), block#28
(composer quoting), block#67 (memory/chat surfaces), block#113 (shared transcript scroll
container), and block#115 (Changes rail PR context). Rebase/conflict review should
pay special attention to ChatView.tsx, ACP connection setup, chat i18n, and
session lifecycle files.

Refs block#114

Refs aaif-goose/goose#11346

@lnittman
lnittman force-pushed the feat/acp-form-elicitation branch 3 times, most recently from 555b959 to c2f1205 Compare August 20, 2026 15:29
…lements

The vendored client advertised the whole `Client` surface in its callback
type but wired only a few methods, so a consumer that implemented
filesystem, terminal, elicitation completion, or a generic extension
method saw its handler silently ignored — the request failed as
unimplemented with no indication which side dropped it.

Registration now follows what the consumer actually provides, including
the generic `extMethod` and `extNotification` handlers, so the callback
type and the wire behaviour describe the same thing.
An unanswered question can outlive the window that showed it. Keeping the
draft in web storage tied it to a renderer that may be replaced, and put
agent-supplied content in a store shared with unrelated state.

Drafts live in a versioned file under the app data directory, written
through a mutex so concurrent windows cannot interleave writes, with a
size ceiling so a large form cannot grow the store without bound. Parsing
is by version; an unreadable record is discarded rather than trusted.
Berd advertises `elicitation.form` and renders a form request from an
agent as a real control rather than prose, returning a typed response on
the originating request.

The parts that are easy to get wrong, and how they are handled:

- A property type this build does not understand is preserved and shown
  as unsupported rather than rendered as a control that misdescribes it.
  A required one blocks acceptance instead of being submitted empty, and
  no value is ever returned for a field the user could not see.
- An unsupported mode or scope returns invalid params, so an agent can
  tell a client limitation from a considered decline. A synthetic user
  `cancel` would conflate the two.
- Untrusted schemas are bounded before rendering — field and option
  counts, serialized schema bytes, message and description lengths. An
  oversized form is rejected at the request boundary rather than
  truncated, since a truncated form can be accepted while misrepresenting
  what was asked.
- Agent-supplied `pattern`s are treated as unevaluable. A native regex
  cannot be interrupted, so evaluating one is an unbounded cost taken on
  an attacker's behalf; claiming to enforce it while timing it afterwards
  would be dishonest about a boundary that does not exist.
- Credential-marked fields are unsupported. ACP form mode is specified
  for non-sensitive data, and a password input here would invite
  collection this surface should not perform.
- Every edit is addressed by question id, so an event from a control that
  has just been replaced cannot land on its replacement.
- Answers to a question whose responder is gone are claimed before they
  are sent as an ordinary message, so a reattaching responder cannot also
  be accepted and the backend cannot see both a message and a response.
- Drafts are scoped by account, workspace, provider, and connection, and
  parsed through a versioned schema rather than cast.
A question belongs to one conversation. Rendering it from shared state
meant a request raised by a background session could appear over whatever
the user was reading, taking the composer of a chat that had asked
nothing.

Presentation is scoped to the asking session, and a live question always
outranks a recovered draft so a draft cannot hide a request an agent is
actually blocked on. A security confirmation owns the composer slot alone
while it is up; a question waits behind it rather than stacking two
interactive surfaces in the same place.
A question asked by a chat the user is not looking at is invisible, and
the agent stays blocked until they happen to return to it. The row now
carries the waiting state in the attention slot it already owns.

A live question and a recovered draft are distinguished rather than
merged: only a live one suppresses the running animation, because only a
live one means an agent is waiting right now.
Session ids are not unique across accounts, so a draft saved under one
account could be matched by a session id belonging to another. Drafts are
cleared when the account or workspace changes, before any query cache is
invalidated, so a restored answer cannot cross that boundary.
@lnittman
lnittman force-pushed the feat/acp-form-elicitation branch from d9c9abc to 958ec0d Compare August 20, 2026 22:02
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