Skip to content

Make multiple-choice the default ask-shape via a generic tool-result→choice contract (chat box stays the universal escape hatch) #61

Description

@chubes4

Summary

When Roadie needs the user to pick from a discrete set of options, it sometimes writes the choices out as a markdown bullet list instead of surfacing them as clickable choices. No present_question tool call is emitted, so the chat package's QuestionCard renderer never fires and the user gets no buttons — they have to type a free-text answer.

The shallow framing is "the model should call present_question more." We are explicitly not fixing this with stronger/negative system-prompt constraints ("you MUST / NEVER write prose"). The real problem is architectural: the choice-vs-prose decision is left to the model on every turn, and the data needed to render choices deterministically already exists inside the tool handlers but gets flattened into prose hints for the model to re-decide.

Design principle (the fix): Multiple-choice is the default ask-shape; the chat box is the universal escape hatch. Most questions an agent asks are answerable as a small choice set (yes/no, pick-one-of-N, confirm/cancel). A generic framework should make presenting choices the natural default whenever a tool already holds a bounded candidate set — deterministically, from data, with no per-moment or per-tool wiring — while the chat input always remains a non-blocking override: the user can type anything (including "none of those") and the conversation flows on. Because the card is always dismissible, the agent is never wrong to surface choices — worst case they're ignored. That asymmetry is what makes choices safe as the default.

Repro (real production context)

  • User: qrisg (team member, extra_chill_team)
  • Surface: Roadie frontend chat on extrachill.com
  • Observed: Roadie asked "Which repo should this issue go to?" and rendered the options as a markdown list (- **extrachill-blog (main)** (homepage rendering/blocks) …) with no clickable buttons. qrisg had to type "yes" to answer.
  • Expected: that prompt renders as clickable choices; clicking sends the choice back as the user's turn; and typing free-text instead is equally valid and just continues the conversation.

The "which repo" phrasing maps exactly to the file_feature_request repo-disambiguation path (analysis below).

Why this is architectural, not a prompt-tuning problem (grounded in code)

The chat package's renderer registry is keyed by tool name, not by result shape:

  • frontend-agent-chat/src/AgentChat.tsx:1142-1153toolRenderers maps specific tool names to renderers: present_question: questionRenderer, and the diff renderer is registered for edit_post_blocks / replace_post_blocks / insert_content (:1148-1151). A tool result renders as a QuestionCard only if its tool name is present_question.

That means the only way to get a card today is for the model to choose to emit a present_question tool call. The handlers that actually know the choice set don't get to render one — they stringify it:

  • extrachill-roadie/inc/tools/class-file-feature-request.php:499-508 — the multi-plugin path computes $repo_candidates (an array of repos) and then builds a prose disambiguation string: "re-file against one of: …". The structured choice set exists in the handler and is deliberately downgraded to prose for the model to re-render. This is the "Which repo?" repro.
  • extrachill-roadie/inc/tools/class-file-feature-request.php:584-599list_recent_issues returns a normalized issues array plus a prose next_step: "propose commenting on that existing issue." Another bounded candidate set flattened into a hint.

And the surfacing decision is left entirely to soft prompt guidance:

  • extrachill-roadie/inc/agent-mode/register.php:387 & :369 — "Branching choices … call present_question" (advisory).
  • extrachill-roadie/inc/tools/class-present-question.php:62 — "Prefer this over free-text questions" (advisory).

So two tool descriptions disagree about how to ask (file_feature_request says "ask"; present_question says "prefer the card"), and the model resolves it toward prose. No amount of prompt hardening removes the root cause: the choice set lives in handler data but the rendering is gated on a model decision.

Note: present_question's own docblock (class-present-question.php:14-17) calls the renderer "tool-name-agnostic," but the actual wiring at AgentChat.tsx:1151 is tool-name-keyed. That gap is part of what this issue closes.

Proposed architecture — a generic tool-result→choice contract

Move the determinism into the substrate (chat package + Data Machine tool return contract) so handlers emit structured candidate data and the framework renders choices. No tool names baked into the choice layer; no per-moment branches.

  1. Generic result→choice rendering (chat package + DM contract). Render a QuestionCard for any tool result that carries a choice/candidate payload (e.g. result.question + result.choices, or a documented candidates shape), not only when the tool name is present_question. Concretely: change the tool-name-keyed dispatch at AgentChat.tsx:1142-1153 so the question renderer fires on result shape, OR is registered for any tool that opts into the contract. The choice layer must contain zero hardcoded tool names — adding a new candidate-producing tool later gets choice rendering for free (this is the RULES.md layer-purity test: new "asks a question" surfaces are data/config, not code changes in the choice framework).

  2. Choices as the default ask-shape; freeform always live. allow_freeform already exists in the contract (class-present-question.php:93-96) — make freeform the always-on default posture, not an opt-in flag. A rendered card is a non-blocking affordance: it never disables the chat input, never locks the thread, never requires a click. Clicking sends the choice's message as a turn (round-trip already implemented in present_question); typing anything else — including dismissal — is just the next turn the agent loop interprets.

  3. Roadie handlers return structured candidates instead of prose hints. file_feature_request is the first consumer, via the generic contract (not bespoke wiring):

    • repo disambiguation (class-file-feature-request.php:499-508) returns $repo_candidates as structured choices instead of the disambiguation prose string.
    • dedupe (class-file-feature-request.php:584-599) returns the issues list as "comment on #N vs. file new" choices instead of the prose next_step.
      These consume the shared contract; they do not register renderers or name themselves in the choice layer.

This keeps the system fluid: the choice set is additive context, not a state machine — there is no awaiting_choice lock anywhere, and a typed answer always wins. It also structurally avoids the #53 loop (Roadie spamming present_question and never filing): the card is a one-shot render off handler data the moment the handler is genuinely blocked on N candidates, not the agent re-deciding to ask again, and a freeform reply ends it immediately.

What we are explicitly NOT doing

  • ❌ Negative/"MUST/NEVER" system-prompt constraints. We don't make the model work harder; we remove the decision.
  • ❌ Hardcoding specific moments ("if multi-repo, render a card"). That's special-casing by another name. The contract is generic; consumers are data.
  • ❌ Blocking/gating the conversation on a choice. The chat box is always the escape hatch.

Acceptance criteria

  • A tool handler that holds a bounded candidate set (starting with file_feature_request repo-disambiguation and dedupe) deterministically surfaces it as clickable choices — the rendering does not depend on a model decision to call present_question.
  • The choice-rendering layer (chat package renderer + DM contract) contains no hardcoded tool names; a new candidate-producing tool renders choices with no change to the choice layer.
  • Any rendered choice set is non-blocking: the chat input stays enabled, the user can type a free-form answer or dismiss the choices, and the conversation continues. Freeform is the default posture, not an opt-in flag.
  • The "Which repo should this issue go to?" repro renders as clickable choices, and qrisg (or anyone) can equally just type the answer.

Cross-references (related but distinct)

Files referenced

  • frontend-agent-chat/src/AgentChat.tsx:1142-1153 (tool-name-keyed renderer registry — the seam to generalize)
  • extrachill-roadie/inc/tools/class-file-feature-request.php:499-508, 584-599 (candidate sets flattened to prose)
  • extrachill-roadie/inc/tools/class-present-question.php:14-17, 62, 93-96 (renderer contract + allow_freeform seed)
  • extrachill-roadie/inc/agent-mode/register.php:369, 387 (soft guidance being removed as the load-bearing mechanism)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions