✨ harness: ask_user tool — the agent can stop and ask the human (ACP elicitation) - #161
✨ harness: ask_user tool — the agent can stop and ask the human (ACP elicitation)#161ibolton336 wants to merge 7 commits into
Conversation
…urn elicitation)
A question the agent writes in prose is never seen until the run is over,
and a turn that ends on a question is a finished run. Steer can nudge a
running turn but cannot make the agent wait. This gives the agent a real
"stop and confirm":
- internal/askuser: a minimal stdio MCP server that is the harness binary
itself (`migration-harness ask-user-mcp`, hidden subcommand), exposing
one tool, ask_user(question, options?). A call becomes an MCP
elicitation/create back to goose with a flat form schema (required
string "answer", enum when options are given); the tool result tells the
model "The human answered: …", or that the human declined / nobody
answered — never an invented answer. Tool calls run on their own
goroutines so the reader keeps draining; stdin EOF fails pending calls.
- acp: initialize advertises clientCapabilities.elicitation.form (goose
v1.45 gates relaying MCP elicitation on it); MCPServer takes the ACP
stdio shape (name + env as a name/value list); elicitation/create asks
are forwarded to viewers through the PermissionForwarder
(ForwardElicitation) and fail closed — no viewer, timeout, or no tee
answers {action:"cancel"}.
- tee: ForwardPermission/ForwardElicitation share forwardAsk (kperm-*/
kask-* ids, first answer wins); pending asks are replayed to a viewer
that attaches mid-question (a question waits minutes; the harness ring
alone would not carry it); answers to either prefix are intercepted off
the viewer pipe.
- main: HARNESS_HITL_ASK (default on) mounts the tool in session/new's
mcpServers and adds a working guideline telling the model to call
ask_user for human decisions instead of asking in prose.
Tests: askuser round trip over pipes (initialize/tools/list/tools/call →
elicitation → accept/decline/cancel/error/EOF), session elicitation
relay + fail-closed matrix, tee elicitation relay with late-viewer replay,
prompt guideline placement; whole harness green with -race. Live probe
(integration tag, real goose 1.45 + Bedrock): the model called ask_user,
the question reached the viewer as kask-1 with the enum schema, the turn
stayed blocked until the viewer answered "postgres", and the final
sentence named it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: ibolton336 <ibolton@redhat.com>
…user The live ROKS test showed steer is a nudge, not a gate: "pause for a status report" was read and then the agent carried on. The ask_user guideline now tells the model that when a human's redirect asks it to pause, check in, report status or wait, it must call ask_user with its status and the choices it sees, and not continue until the tool answers — steer and the blocking question compose. Signed-off-by: ibolton336 <ibolton@redhat.com>
Signed-off-by: ibolton336 <ibolton@redhat.com>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
savitharaghunathan
left a comment
There was a problem hiding this comment.
Thanks for the PR, Ian. This is great.
One quick question: do we need to record these design decisions in an ADR somewhere? Might make it easier when we add new agent runtime support (if and when) based on usage.
Left a few inline notes below on edge cases surfaced during review — none blocking, but worth a look.
| defer func() { | ||
| s.mu.Lock() | ||
| delete(s.perms, id) | ||
| delete(s.pendingAsks, id) |
There was a problem hiding this comment.
Race: replayed asks can outlive their answer
resolveAsk() deletes s.perms[id] and sends the result on ch before forwardAsk()'s deferred cleanup (delete(s.perms, id); delete(s.pendingAsks, id)) runs. A viewer that attaches in that gap gets the already-answered question replayed from pendingAsks via serveViewer's backlog snapshot. If they answer it, resolveAsk() finds no entry in s.perms and silently drops the reply ("late/duplicate answer... ignoring") — the viewer gets no feedback that their answer went nowhere.
Suggest deleting from pendingAsks synchronously at the point the answer is sent/resolved (before the channel send), rather than in the deferred cleanup after forwardAsk wakes up, so there's no window where an already-resolved ask is still visible to new viewers.
There was a problem hiding this comment.
Good catch — the window between resolveAsk's channel send and forwardAsk's deferred cleanup is real. Fixed in 5d21888 exactly as you suggest: the pendingAsks frame is dropped in the same critical section that commits the answer (the deferred cleanup still covers timeout/shutdown), with a test pinning the invariant.
| _ = json.Unmarshal(msg.Params, ¶ms) | ||
| title := params.Message | ||
| if len(title) > 80 { | ||
| title = title[:77] + "..." |
There was a problem hiding this comment.
Byte-offset truncation can split a UTF-8 rune
title := params.Message
if len(title) > 80 {
title = title[:77] + "..."
}len() and slicing here operate on bytes, not runes. A non-ASCII message (accented chars, CJK, emoji) cut at byte 77 can land mid-rune, producing invalid UTF-8 in the log line.
Suggest truncating on a rune boundary, e.g. []rune(title) with a rune-count check, or utf8.RuneCountInString + iterating rune boundaries — or pull in a small helper like strings.ToValidUTF8 after truncation as a cheap safety net.
There was a problem hiding this comment.
Fixed in 5d21888 — truncation now happens on rune boundaries ([]rune slice), so a non-ASCII question can't be cut mid-sequence.
| Name string `json:"name"` | ||
| Command string `json:"command"` | ||
| Args []string `json:"args"` | ||
| Env []EnvVar `json:"env"` |
There was a problem hiding this comment.
omitempty was dropped in the map→slice migration; Env can now marshal as null
The comment above this struct explains the switch from map[string]string to []EnvVar exists specifically because goose's untagged-enum MCP-server parser rejects a bare map (-32602). But Env lost its omitempty tag, and nothing guarantees a caller sets it — a nil slice marshals to "env":null, which risks tripping the exact same parser failure this refactor was meant to fix.
Today's two call sites (main.go, integration test) happen to pass Env: []acp.EnvVar{} explicitly, so this is latent rather than active. Suggest either restoring omitempty (if null/missing is actually accepted) or adding a constructor/MarshalJSON that defaults nil to []EnvVar{}, so future call sites can't silently regress this.
There was a problem hiding this comment.
Fixed in 5d21888, via your second option: MCPServer.MarshalJSON normalizes nil to []. I deliberately avoided omitempty — the untagged parse is only proven against the present-and-array shape, and an absent field is as unproven against it as null. Args had the same latent shape, so it's normalized too, and a test locks the wire shape.
Records why asking is a tool call, why MCP elicitation is the carrier, the kask-*/kperm-* split, fail-closed policy, and the acceptance checklist a future agent runtime must meet to compose with this flow. Answers the review question about capturing these decisions durably. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: ibolton336 <ibolton@redhat.com>
- tee: resolveAsk drops the pendingAsks replay frame in the same critical section that commits the answer, closing the window where a viewer attaching mid-resolve was offered a dead question whose reply then vanished as late/duplicate. Invariant pinned by a test. - acp: MCPServer marshals nil args/env as [] (never null) — goose's untagged-enum parse is only proven against the present-and-array shape; omitempty would trade one unproven shape for another. - acp: elicitation log title truncates on rune boundaries instead of bytes, so non-ASCII questions cannot be split mid-rune. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: ibolton336 <ibolton@redhat.com>
|
Thanks @savitharaghunathan — good call on the ADR. Added as All three inline notes are addressed in 5d21888; replies on each thread. Harness suite green with |
…r#161) djzager's konveyor#145 (Gateway CRD as interim execution interface, opened 08-17) already holds 0016; maintainer series wins, as with the 0009 renumbering. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: ibolton336 <ibolton@redhat.com>
| } | ||
| _ = json.Unmarshal(f.Params, &p) | ||
| version := protocolVersion | ||
| if p.ProtocolVersion != "" && p.ProtocolVersion <= protocolVersion { |
There was a problem hiding this comment.
elicitation landed in 2025-06-18 (https://modelcontextprotocol.io/specification/2025-06-18/changelog, item 6), but this echoes back any version that sorts <= ours, so a client initializing at 2025-03-26 gets agreement and then an elicitation/create that doesn't exist in that revision. The Containerfile pulls goose from releases/download/stable, so which version we get isn't pinned either. Since the whole server is elicitation, should it just always answer protocolVersion?
There was a problem hiding this comment.
Yes — agreed. Fixed in 194b8aa: initialize now always answers 2025-06-18, whatever the client offers. Agreeing to an older revision promised a protocol this server can't keep — elicitation is the whole server — so a pre-elicitation client now fails at the handshake (with the mismatch logged) instead of on the first elicitation/create mid-turn. A test pins the 2025-03-26 offer.
Agreed on the unpinned goose pull too — that predates this PR (agent-base pulls stable), so I'd rather split it into a follow-up issue than grow this one. Happy to file it.
The negotiation echoed back any client offer that sorted <= ours, so a client initializing at 2025-03-26 got agreement — and then an elicitation/create that does not exist in that revision. Elicitation is the entire server, so initialize now always answers 2025-06-18 and logs when the client offered something else; a client that cannot speak it disconnects at the handshake instead of failing on the first question mid-turn. A test pins the pre-elicitation offer. Raised by Fabian in the konveyor#161 review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: ibolton336 <ibolton@redhat.com>
Why
A question the agent writes in prose is never seen until the run is over, and a turn that ends on a question is a finished run. The tee (#96) lets a human watch and steer, but steer is a nudge into a running turn — it cannot make the agent wait. Seen live today: an execute-stage run found no
PLAN.md, asked "Would you like me to 1/2/3?" in prose, then answered itself and wandered off to do the plan stage; a "pause for a status report" steer was read and then it carried on.This gives the agent a real in-turn stop and confirm — the "how does interactive input reach the agent" piece of #55 — using ACP's own primitive for it.
What
ask_usertool.internal/askuseris a minimal stdio MCP server that is the harness binary itself (migration-harness ask-user-mcp, hidden subcommand), exposing one tool:ask_user(question, options?). A call becomes an MCPelicitation/createback to goose with a flat form schema (required stringanswer, enum when options are given). The tool result tells the model "The human answered: …", or that the human declined / nobody answered — never an invented answer. No new image dependencies: the harness lists itself insession/newmcpServers.clientCapabilities.elicitation.form(goose ≥ 1.45 gates relaying MCP elicitation on it), andelicitation/createasks go through the same forwarder as permission asks (ForwardElicitation). Fail closed everywhere: no viewer, no answer withinHARNESS_HITL_TIMEOUT_SECONDS, or no tee →{action: "cancel"}.MCPServertakes the ACP stdio shape (name+envas a name/value list).kask-<n>ids (permission asks keepkperm-<n>; first answer wins, answers intercepted off the viewer pipe), and replays a pending ask to a viewer that attaches mid-question — a question waits minutes, and the harness status ring alone would not carry it.ask_userfor decisions only a human can make, don't ask in prose; and if a human's mid-turn redirect asks to pause / check in / report status / wait, callask_userwith status + choices and do not continue until it answers — so steer and the blocking question compose.HARNESS_HITL_ASK=offleaves the tool out (default on, likeHARNESS_HITL_STEER). README + changelog fragment included.Verification
askuserround trip over pipes (initialize → tools/list → tools/call → elicitation → accept / decline / cancel / error / EOF), session elicitation relay + fail-closed matrix, tee relay with late-viewer replay, prompt guideline placement. Whole harness green with-race.TestAskUserBlocksLiveRun): the model calledask_user, the question reached the viewer askask-1with the enum schema, the turn stayed blocked until the viewer answeredpostgres, and the final sentence named it.Not in this PR / follow-ups
HARNESS_HITL_TIMEOUT_SECONDS, 180 s default); a question probably wants its own, longer default.HITL.mov
Refs #55, #56, #96.