Add ask_user tool: agent-posed multiple-choice questions (#2111)

Let the agent pause and ask the user a multiple-choice question when a
task is genuinely ambiguous and the answer changes what it does next —
choosing between approaches, confirming an assumption, picking a target —
instead of guessing.

Modeled on the existing `ui_control` marker pattern: the `ask_user` tool
returns an `ask_user` payload that the agent loop emits as an SSE event
and then ends the turn. The frontend renders the question with clickable
option buttons, a free-text "Other" input, and an x to dismiss; the user's
choice is sent as the next message and the agent resumes with it in
context.

- src/tool_execution.py: `ask_user` handler — pure UI marker, no I/O.
  Validates a non-empty question + 2..6 options, normalizes string/object
  options, returns the payload.
- src/agent_loop.py: emit the `ask_user` event and break the round loop so
  the turn ends and waits for the user's selection. Stream the question as
  assistant text so it persists/replays (prevents a re-ask loop).
- Registration: TOOL_TAGS, ALWAYS_AVAILABLE, BUILTIN_TOOL_DESCRIPTIONS,
  FUNCTION_TOOL_SCHEMAS, the system-prompt blurb. Not admin-gated (any
  user can be asked); the structured args serialize via the default
  json.dumps path.
- routes/chat_routes.py: relay the `ask_user` event to the client.
- static/js/chat.js + static/style.css: render the question card (options +
  free-text Other + dismiss x; removed once answered). Reuses CSS vars and
  the .modal-close button; emoji go through the monochrome-SVG pipeline.
  Bump chat.js cache pin.
- tests/test_ask_user_tool.py: payload, multi flag, string options, option
  cap, validation errors, serializer round-trip, registration.
This commit is contained in:
Kenny Van de Maele
2026-06-05 11:49:11 +02:00
committed by GitHub
parent 621885ac06
commit 0a2adc9c96
9 changed files with 432 additions and 1 deletions

View File

@@ -36141,3 +36141,78 @@ body.theme-frosted .modal {
0% { box-shadow: 0 0 0 2px var(--accent, var(--red)); }
100% { box-shadow: 0 0 0 2px transparent; }
}
/* ask_user: multiple-choice question card
The agent posed a question and ended its turn. The user clicks an option,
types a free-text "Other" answer, or dismisses (×) to just type in the
composer. Reuses theme vars (and .modal-close for the ×) so it reads as
part of the conversation, not a modal. */
.ask-user-card {
/* Left-align like an assistant message (.msg-ai), not centered. */
align-self: flex-start;
margin: 10px auto 10px 8px;
width: 85%;
max-width: 680px;
padding: 12px 16px 14px;
border: 1px solid var(--border);
border-radius: 12px;
background: color-mix(in srgb, var(--fg) 4%, var(--panel));
}
/* Focused only programmatically (tabIndex -1) to move SR/keyboard position; no
visible outline on the whole card box. */
.ask-user-card:focus { outline: none; }
.ask-user-head {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.ask-user-close { font-size: 15px; }
.ask-user-question {
margin: -2px 0 10px;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
color: var(--fg);
}
.ask-user-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.ask-user-option {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
width: 100%;
/* Match the height of the free-text input below (.styled-prompt-input). */
min-height: 39px;
text-align: left;
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
color: var(--fg);
font-size: 13px;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
.ask-user-option:hover:not(:disabled) {
border-color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 10%, var(--panel));
}
.ask-user-option:disabled { cursor: default; }
.ask-user-option-label { font-weight: 500; }
.ask-user-option-desc { opacity: 0.65; font-size: 12px; }
/* Free-text "Other" row: input + send, on one line. */
.ask-user-other {
display: flex;
gap: 8px;
margin-top: 10px;
}
/* Reuses .styled-prompt-input; override its full-width + top margin so it
sits inline in the flex row next to the send button. */
.ask-user-other-input { flex: 1; min-width: 0; width: auto; margin-top: 0; }
/* Reuses .confirm-btn .confirm-btn-primary; flex-row deltas + height match to
the input beside it (.confirm-btn won't stretch on its own). */
.ask-user-other-send { flex-shrink: 0; white-space: nowrap; min-height: 39px; }
.ask-user-other-send:disabled { opacity: 0.5; cursor: default; }