diff --git a/run.json b/run.json
index e031c502e..3f46c83bb 100644
--- a/run.json
+++ b/run.json
@@ -493,7 +493,7 @@
"kind": "running"
},
"status_updated_at": "2026-05-23T16:30:00.579662Z",
- "last_event_at": "2026-05-23T17:14:03.422520Z",
+ "last_event_at": "2026-05-23T17:36:14.461171Z",
"pending_control": null,
"checkpoints": [
{
@@ -741,9 +741,9 @@
}
},
{
- "seq": 0,
+ "seq": 1024,
"checkpoint": {
- "timestamp": "2026-05-23T17:14:03.544702Z",
+ "timestamp": "2026-05-23T17:14:07.818229Z",
"current_node": "implement",
"completed_nodes": [
"start",
@@ -754,30 +754,157 @@
],
"node_retries": {},
"context_values": {
+ "response.implement": "Implemented the mid-stage agent interview tools and metadata plumbing.\n\nSummary of changes:\n- Extended canonical `InterviewOption` with optional `description` and `preview`.\n- Updated OpenAPI and regenerated the TypeScript API client:\n - `ApiQuestion.options` now uses `InterviewOption`.\n - Removed stale `ApiQuestionOption` export/file.\n- Added model-native agent question tools:\n - OpenAI: `request_user_input`\n - Anthropic: `AskUserQuestion`\n - Gemini: no question tool\n- Added per-turn `AgentToolRuntime` so cached sessions receive current workflow/stage interview context dynamically.\n- Enforced question-tool round rules:\n - question tool runs alone\n - mixed tool peers get model-visible errors\n - multiple separate question-tool calls execute only the first and tell the model to batch\n- Added shared run-level interview blocker/guard used by both human gates and agent questions.\n- Added workflow runtime for agent-originated batched questions:\n - emits/registers all questions first\n - blocks once per batch\n - waits concurrently\n - emits completion/interruption/timeout events per question\n - cleans up pending questions on cancellation/drop\n- Preserved answer endpoints/paths through existing pending interview projection and submission flow.\n- Updated web/Slack display:\n - web shows option descriptions\n - Slack renders option descriptions where practical\n - preview is captured/exposed but not specially rendered\n- Added/updated tests across agent, workflow, API, store, Slack, and web.\n\nValidation run:\n- `cargo check -q -p fabro-agent -p fabro-workflow -p fabro-api -p fabro-server -p fabro-slack -p fabro-store -p fabro-cli --tests` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy -q -p fabro-agent -p fabro-workflow -p fabro-api -p fabro-server -p fabro-slack -p fabro-store -p fabro-cli --all-targets -- -D warnings` ✅\n- `cargo nextest run -p fabro-agent -p fabro-workflow -p fabro-api ...focused question/API tests...` ✅\n- `cargo nextest run -p fabro-interview` ✅\n- `cargo nextest run -p fabro-slack -p fabro-store --lib --tests` ✅\n- `cd apps/fabro-web && bun test ./app/components/interview-dock.test.tsx ./app/components/stage-renderers/helpers.test.ts` ✅\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `cd lib/packages/fabro-api-client && bun run typecheck` ✅\n\nNoted failures:\n- `cargo nextest run -p fabro-server ...` fails on `server::tests::get_graph_returns_svg`; rerun of the single test also fails. The error is the graph renderer subprocess returning nextest output (`running 0 tests`) instead of SVG, unrelated to the interview changes.\n- Full `cd apps/fabro-web && bun test` still has existing unrelated failures in run-detail/lifecycle tests; focused changed web tests pass and typecheck passes.",
+ "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126",
+ "failure_signature": "",
"graph.goal": "# Mid-Stage Agent Interview Tools\n\n## Summary\n\nAdd model-native question tools that let agents pause mid-stage and ask the human for input through Fabro's existing interview system.\n\nOpenAI-profile agents get `request_user_input`; Anthropic-profile agents get `AskUserQuestion`. When either tool is called, Fabro creates pending interview questions, surfaces them through the existing web/API/Slack paths, waits for answers, then returns provider-shaped tool results so the model can continue the same stage.\n\n## Key Changes\n\n- Extend the existing interview contract without type sprawl:\n - Add optional `description` and `preview` fields to the canonical `fabro_types::InterviewOption`; reuse that type through `fabro-api` replacements instead of introducing `AgentQuestionOption`, API-only aliases, or adapter-only duplicate types.\n - Update OpenAPI, generated Rust/TypeScript clients, event conversion, projection, Slack/web mappers, and the existing `with_replacement(\"InterviewOption\", \"fabro_types::InterviewOption\", ...)` parity tests.\n - Treat both fields as untrusted model-authored display data. Store and expose them after enforcing bounded lengths; truncate or reject oversized values consistently before persistence.\n - Initial UI behavior: display `description` under option labels where practical. Capture and expose `preview`, but do not render preview content specially in web or Slack v1.\n\n- Add a shared run-level interview runtime:\n - Move the private human-node blocked-state refcount into a reusable run-level guard used by both `HumanHandler` and agent question tools, so `RunUnblocked` is emitted only when all human and agent interviews for the run are resolved.\n - Runtime accepts the interviewer, workflow emitter, stage scope, stage id, tool call id, and normalized questions.\n - Support batch asks as a first-class operation: emit/register all questions first, mark the run blocked once, await all answers concurrently, then emit completion/timeout/interrupted events per question and unblock when the batch resolves.\n - Batch support applies only to multiple `questions[]` inside one question-tool call. Do not aggregate multiple separate question-tool calls from the same model round.\n - Generate safe internal question IDs with a ULID/UUID plus stage visit/tool-call context; store original model question IDs/text in question metadata for provider result mapping.\n\n- Add provider-specific agent tools:\n - `request_user_input` for `AgentProfileKind::OpenAi`.\n - Accept Codex-compatible schema: `questions[]` with `id`, `header`, `question`, and `options[] { label, description }`.\n - Normalize each question to `QuestionType::MultipleChoice` with `allow_freeform: true`.\n - Return JSON text matching Codex shape, keyed by the original model question ID: `{\"answers\":{\"id\":{\"answers\":[\"...\"]}}}`.\n - `AskUserQuestion` for `AgentProfileKind::Anthropic`.\n - Accept Claude-compatible schema: `questions[]` with `question`, `header`, `options[] { label, description, preview? }`, and `multiSelect`.\n - Normalize single-select to `MultipleChoice`, multi-select to `MultiSelect`, always with `allow_freeform: true`.\n - Return Claude-style tool result text keyed by the original question text: `User has answered your questions: \"...question...\"=\"answer\". You can now continue...`.\n - Answer formatting for both tools returns user-facing option labels to the model. Preserve internal option keys for validation and event storage. For multi-select, preserve the submission order supplied by the answer path.\n\n- Thread workflow interview context into agent tool execution:\n - Add an explicit per-turn agent tool runtime context passed into `process_input` or an adjacent `process_input_with_runtime` API. It carries the interviewer, workflow emitter, stage scope/id, shared block guard, and provider answer formatter.\n - Do not capture stage-specific interview handles in the profile registry or cached session construction; cached full-fidelity sessions must receive the current turn's stage context dynamically.\n - Child/subagent sessions must not expose these question tools. If somehow called outside the root session, return a model-visible error.\n - Question tools must execute alone in a model tool round. If a round contains one question tool plus any other tool call, execute the question tool and return model-visible error results for the non-question peers, preserving tool-call/tool-result ordering. If a round contains multiple separate question-tool calls, execute only the first and return model-visible error results for the later question-tool calls instructing the model to combine questions into one `questions[]` batch.\n - Agent-originated questions have no per-question timeout in v1 because the provider schemas do not include timeout. They rely on existing stage timeout, wall-clock timeout, cancellation, and interruption behavior.\n\n- Preserve existing answer paths:\n - Do not add a new answer endpoint.\n - Continue using `GET /runs/{id}/questions` and `POST /runs/{id}/questions/{qid}/answer`.\n - Keep `ControlInterviewer`, web `InterviewDock`, Slack blocks, and run projection as the delivery mechanism.\n\n## Test Plan\n\n- Unit tests for schema parsing and normalization:\n - Codex request with descriptions maps to Fabro multiple-choice questions and returns answers by model question ID.\n - Claude request with `multiSelect: true` maps to `MultiSelect` and returns comma-separated answer text.\n - Batched Codex and Claude requests surface all questions as pending before awaiting answers, then return one result with every answer mapped to the original model ID/text.\n - Optional `preview` and `description` survive event, projection, API conversion, OpenAPI replacement tests, and TypeScript client generation.\n - Oversized `description`/`preview` values are bounded before persistence and never rendered as trusted HTML.\n\n- Workflow and agent tests:\n - OpenAI-profile session advertises `request_user_input`; Anthropic-profile session advertises `AskUserQuestion`; Gemini advertises neither.\n - Subagent profiles do not advertise the question tools.\n - Root agent can ask a question and resume after the answer.\n - Subagent or missing interview context returns a clear tool error.\n - Cached full-fidelity session emits interview events against the current stage, not the original cached stage.\n - A mixed tool round containing a human-question tool plus another tool preserves all required tool results and rejects the peer calls with model-visible errors.\n - A round with multiple separate question-tool calls executes only the first and rejects later question-tool calls with model-visible errors.\n\n- Server, projection, and UI tests:\n - `InterviewStarted` with option metadata appears in pending questions.\n - Submitting valid selected, multi-selected, and freeform answers unblocks the waiting tool.\n - Duplicate answer submission remains rejected through existing accepted-question logic.\n - Parallel human gate plus agent question keeps the run blocked until both are answered.\n - Pause, cancel, and interrupt while an agent question is waiting resolve pending questions consistently and do not leave the run blocked.\n - Stage timeout or wall-clock timeout while an agent question is waiting interrupts the batch; no per-question timeout event is expected unless a future schema adds timeout.\n - Slack answer submissions work for agent-originated questions using the same pending interview transport.\n\n- Run checks:\n - `cargo nextest run -p fabro-interview -p fabro-workflow -p fabro-server -p fabro-agent`\n - `cd apps/fabro-web && bun test && bun run typecheck`\n - Regenerate and verify OpenAPI-derived Rust and TypeScript clients after schema changes.\n\n## Assumptions\n\n- This feature is only for in-process/API-backed agent sessions, not ACP external agents in v1.\n- `preview` is stored and exposed but not rendered specially in the first implementation.\n- Human-question tools are available only during root agent execution inside a workflow run with an active interviewer.\n- Existing interview events remain the source of truth for pending questions; no separate agent-question event family is added.\n- The implementation should prefer extending existing interview structs and replacement mappings over adding parallel API DTOs or conversion-only aliases.\n",
+ "graph.rankdir": "LR",
+ "internal.node_visit_count": 1,
+ "internal.retry_count.implement": 0,
+ "failure_class": "",
+ "internal.retry_count.preflight_lint": 0,
+ "internal.retry_count.start": 0,
+ "internal.run_id": "01KSATQNAXG41FHKV0QH5N1QGC",
+ "internal.thread_id": "preflight_lint",
"internal.work_dir": "/home/daytona/workspace/fabro",
+ "outcome": "succeeded",
+ "current_node": "implement",
+ "thread.preflight_compile.current_node": "preflight_lint",
+ "thread.toolchain.current_node": "preflight_compile",
+ "thread.preflight_lint.current_node": "implement",
+ "internal.retry_count.toolchain": 0,
+ "last_response": "Implemented the mid-stage agent interview tools and metadata plumbing.\n\nSummary of changes:\n- Extended canonical `InterviewOption` with optional `description` and `preview`.\n- Updated OpenAPI and rege",
+ "internal.fidelity": "compact",
+ "thread.start.current_node": "toolchain",
+ "internal.retry_count.preflight_compile": 0,
+ "last_stage": "implement",
+ "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n "
+ },
+ "node_outcomes": {
+ "preflight_lint": {
+ "status": "succeeded",
+ "context_updates": {
+ "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126"
+ },
+ "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1",
+ "usage": null
+ },
+ "implement": {
+ "status": "succeeded",
+ "context_updates": {
+ "last_response": "Implemented the mid-stage agent interview tools and metadata plumbing.\n\nSummary of changes:\n- Extended canonical `InterviewOption` with optional `description` and `preview`.\n- Updated OpenAPI and rege",
+ "last_stage": "implement",
+ "response.implement": "Implemented the mid-stage agent interview tools and metadata plumbing.\n\nSummary of changes:\n- Extended canonical `InterviewOption` with optional `description` and `preview`.\n- Updated OpenAPI and regenerated the TypeScript API client:\n - `ApiQuestion.options` now uses `InterviewOption`.\n - Removed stale `ApiQuestionOption` export/file.\n- Added model-native agent question tools:\n - OpenAI: `request_user_input`\n - Anthropic: `AskUserQuestion`\n - Gemini: no question tool\n- Added per-turn `AgentToolRuntime` so cached sessions receive current workflow/stage interview context dynamically.\n- Enforced question-tool round rules:\n - question tool runs alone\n - mixed tool peers get model-visible errors\n - multiple separate question-tool calls execute only the first and tell the model to batch\n- Added shared run-level interview blocker/guard used by both human gates and agent questions.\n- Added workflow runtime for agent-originated batched questions:\n - emits/registers all questions first\n - blocks once per batch\n - waits concurrently\n - emits completion/interruption/timeout events per question\n - cleans up pending questions on cancellation/drop\n- Preserved answer endpoints/paths through existing pending interview projection and submission flow.\n- Updated web/Slack display:\n - web shows option descriptions\n - Slack renders option descriptions where practical\n - preview is captured/exposed but not specially rendered\n- Added/updated tests across agent, workflow, API, store, Slack, and web.\n\nValidation run:\n- `cargo check -q -p fabro-agent -p fabro-workflow -p fabro-api -p fabro-server -p fabro-slack -p fabro-store -p fabro-cli --tests` ✅\n- `cargo +nightly-2026-04-14 fmt --check --all` ✅\n- `cargo +nightly-2026-04-14 clippy -q -p fabro-agent -p fabro-workflow -p fabro-api -p fabro-server -p fabro-slack -p fabro-store -p fabro-cli --all-targets -- -D warnings` ✅\n- `cargo nextest run -p fabro-agent -p fabro-workflow -p fabro-api ...focused question/API tests...` ✅\n- `cargo nextest run -p fabro-interview` ✅\n- `cargo nextest run -p fabro-slack -p fabro-store --lib --tests` ✅\n- `cd apps/fabro-web && bun test ./app/components/interview-dock.test.tsx ./app/components/stage-renderers/helpers.test.ts` ✅\n- `cd apps/fabro-web && bun run typecheck` ✅\n- `cd lib/packages/fabro-api-client && bun run typecheck` ✅\n\nNoted failures:\n- `cargo nextest run -p fabro-server ...` fails on `server::tests::get_graph_returns_svg`; rerun of the single test also fails. The error is the graph renderer subprocess returning nextest output (`running 0 tests`) instead of SVG, unrelated to the interview changes.\n- Full `cd apps/fabro-web && bun test` still has existing unrelated failures in run-detail/lifecycle tests; focused changed web tests pass and typecheck passes."
+ },
+ "notes": "Stage completed: implement",
+ "usage": {
+ "input": {
+ "usage": {
+ "model": {
+ "provider": "openai",
+ "model_id": "gpt-5.5"
+ },
+ "tokens": {
+ "input_tokens": 483262,
+ "output_tokens": 43089,
+ "reasoning_tokens": 20029,
+ "cache_read_tokens": 63283712,
+ "cache_write_tokens": 0
+ }
+ },
+ "facts": {
+ "algorithm": "openai"
+ }
+ },
+ "total_usd_micros": 35951706
+ },
+ "files_touched": [
+ "/home/daytona/workspace/fabro/lib/crates/fabro-agent/src/question_tools.rs",
+ "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/interview_runtime.rs"
+ ]
+ },
+ "preflight_compile": {
+ "status": "succeeded",
+ "context_updates": {
+ "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126"
+ },
+ "notes": "Script completed: cargo check -q --workspace 2>&1",
+ "usage": null
+ },
+ "start": {
+ "status": "succeeded",
+ "usage": null
+ },
+ "toolchain": {
+ "status": "succeeded",
+ "context_updates": {
+ "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c"
+ },
+ "notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1",
+ "usage": null
+ }
+ },
+ "next_node_id": "simplify_opus",
+ "git_commit_sha": "61317d13c1ac536f9d663790c11cb95d8c7de458",
+ "node_visits": {
+ "implement": 1,
+ "preflight_compile": 1,
+ "start": 1,
+ "toolchain": 1,
+ "preflight_lint": 1
+ }
+ },
+ "diff": {
+ "patch": "diff --git a/apps/fabro-web/app/components/interview-dock.test.tsx b/apps/fabro-web/app/components/interview-dock.test.tsx\nindex 34a58269f..6fb48bfb9 100644\n--- a/apps/fabro-web/app/components/interview-dock.test.tsx\n+++ b/apps/fabro-web/app/components/interview-dock.test.tsx\n@@ -133,6 +133,27 @@ describe(\"InterviewDock\", () => {\n expect(buttons.Revise).toBeDefined();\n });\n \n+ test(\"multiple choice renders option descriptions as display text\", () => {\n+ const question = makeQuestion({\n+ question_type: QuestionType.MULTIPLE_CHOICE,\n+ options: [\n+ {\n+ key: \"A\",\n+ label: \"[A] Approve\",\n+ description: \"Deploy the current patch\",\n+ preview: \"not rendered specially\",\n+ },\n+ ],\n+ });\n+ const tree = render(\n+ ,\n+ );\n+ const text = textContent(tree.toJSON());\n+ expect(text).toContain(\"Approve\");\n+ expect(text).toContain(\"Deploy the current patch\");\n+ expect(text).not.toContain(\"not rendered specially\");\n+ });\n+\n test(\"freeform question renders a textarea and disables send when empty\", () => {\n const question = makeQuestion({\n question_type: QuestionType.FREEFORM,\ndiff --git a/apps/fabro-web/app/components/interview-dock.tsx b/apps/fabro-web/app/components/interview-dock.tsx\nindex 35eb2fcf5..967044907 100644\n--- a/apps/fabro-web/app/components/interview-dock.tsx\n+++ b/apps/fabro-web/app/components/interview-dock.tsx\n@@ -15,7 +15,7 @@ import {\n import { QuestionType } from \"@qltysh/fabro-api-client\";\n import type {\n ApiQuestion,\n- ApiQuestionOption,\n+ InterviewOption,\n } from \"@qltysh/fabro-api-client\";\n \n import {\n@@ -270,7 +270,7 @@ function ChoiceBody({\n submitting,\n onSubmit,\n }: {\n- options: ApiQuestionOption[];\n+ options: InterviewOption[];\n allowFreeform: boolean;\n submitting: boolean;\n onSubmit: (answer: SubmitInterviewAnswer) => Promise;\n@@ -287,7 +287,7 @@ function ChoiceBody({\n onClick={() => void onSubmit({ kind: \"selected\", option_key: option.key })}\n className={CHOICE_BUTTON}\n >\n- {displayLabel(option.label)}\n+ \n \n ))}\n \n@@ -314,7 +314,7 @@ function MultiSelectBody({\n submitting,\n onSubmit,\n }: {\n- options: ApiQuestionOption[];\n+ options: InterviewOption[];\n submitting: boolean;\n onSubmit: (answer: SubmitInterviewAnswer) => Promise;\n }) {\n@@ -346,7 +346,7 @@ function MultiSelectBody({\n className={isSelected ? CHOICE_BUTTON_SELECTED : CHOICE_BUTTON}\n >\n {isSelected && }\n- {displayLabel(option.label)}\n+ \n \n );\n })}\n@@ -455,6 +455,19 @@ function FreeformBody({\n );\n }\n \n+function OptionLabel({ option }: { option: InterviewOption }) {\n+ return (\n+ \n+ {displayLabel(option.label)}\n+ {option.description && (\n+ \n+ {option.description}\n+ \n+ )}\n+ \n+ );\n+}\n+\n function Spinner() {\n return ;\n }\ndiff --git a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts\nindex 6c03541c0..8426ae141 100644\n--- a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts\n+++ b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts\n@@ -79,6 +79,36 @@ describe(\"parseHumanInterviewPairs\", () => {\n expect(pairs[0].resolution).toBeNull();\n });\n \n+ test(\"preserves option description and preview metadata from started events\", () => {\n+ const events: EventEnvelope[] = [\n+ envelope(1, {\n+ event: \"interview.started\",\n+ properties: {\n+ question_id: \"q-1\",\n+ question: \"Pick a path\",\n+ question_type: \"multiple_choice\",\n+ options: [\n+ {\n+ key: \"ship\",\n+ label: \"Ship\",\n+ description: \"Deploy the current patch\",\n+ preview: \"diff preview\",\n+ },\n+ ],\n+ },\n+ }),\n+ ];\n+\n+ const pairs = parseHumanInterviewPairs(events);\n+\n+ expect(pairs[0].question.options[0]).toEqual({\n+ key: \"ship\",\n+ label: \"Ship\",\n+ description: \"Deploy the current patch\",\n+ preview: \"diff preview\",\n+ });\n+ });\n+\n test(\"captures timeout and interrupted resolutions\", () => {\n const events: EventEnvelope[] = [\n envelope(1, {\ndiff --git a/apps/fabro-web/app/components/stage-renderers/helpers.ts b/apps/fabro-web/app/components/stage-renderers/helpers.ts\nindex 561779a55..676e372ee 100644\n--- a/apps/fabro-web/app/components/stage-renderers/helpers.ts\n+++ b/apps/fabro-web/app/components/stage-renderers/helpers.ts\n@@ -5,6 +5,8 @@ import { getArray, getNumber, getObject, getString, type UnknownRecord } from \".\n export interface InterviewOption {\n key: string;\n label: string;\n+ description?: string | null;\n+ preview?: string | null;\n }\n \n export interface HumanQuestion {\n@@ -54,7 +56,14 @@ function parseInterviewOptions(value: unknown): InterviewOption[] {\n const record = item as UnknownRecord;\n const key = getString(record, \"key\");\n const label = getString(record, \"label\");\n- if (key && label) out.push({ key, label });\n+ if (key && label) {\n+ const option: InterviewOption = { key, label };\n+ const description = getString(record, \"description\");\n+ const preview = getString(record, \"preview\");\n+ if (description !== null) option.description = description;\n+ if (preview !== null) option.preview = preview;\n+ out.push(option);\n+ }\n }\n return out;\n }\ndiff --git a/apps/fabro-web/app/components/stage-renderers/human-qa.tsx b/apps/fabro-web/app/components/stage-renderers/human-qa.tsx\nindex 50dc48cdd..37464ce12 100644\n--- a/apps/fabro-web/app/components/stage-renderers/human-qa.tsx\n+++ b/apps/fabro-web/app/components/stage-renderers/human-qa.tsx\n@@ -184,7 +184,14 @@ function QuestionBlock({\n \n {option.key}\n \n- {option.label}\n+ \n+ {option.label}\n+ {option.description && (\n+ \n+ {option.description}\n+ \n+ )}\n+ \n \n ))}\n {question.allowFreeform && (\ndiff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml\nindex 44bfe394c..933a7228e 100644\n--- a/docs/public/api-reference/fabro-api.yaml\n+++ b/docs/public/api-reference/fabro-api.yaml\n@@ -6941,22 +6941,6 @@ components:\n id:\n type: string\n \n- ApiQuestionOption:\n- description: A selectable option for a multiple-choice or multi-select question.\n- type: object\n- required:\n- - key\n- - label\n- properties:\n- key:\n- type: string\n- description: Machine-readable option key used when submitting an answer.\n- example: option_a\n- label:\n- type: string\n- description: Human-readable label displayed to the user.\n- example: Accept changes\n-\n ApiQuestion:\n description: A pending human-in-the-loop question generated by a workflow stage.\n type: object\n@@ -6986,7 +6970,7 @@ components:\n type: array\n description: Available options for selection-based questions. Empty for freeform questions.\n items:\n- $ref: \"#/components/schemas/ApiQuestionOption\"\n+ $ref: \"#/components/schemas/InterviewOption\"\n allow_freeform:\n type: boolean\n description: Whether the user may provide freeform text in addition to selecting options.\n@@ -7758,8 +7742,16 @@ components:\n properties:\n key:\n type: string\n+ description: Machine-readable option key used when submitting an answer.\n label:\n type: string\n+ description: Human-readable label displayed to the user.\n+ description:\n+ type: [\"string\", \"null\"]\n+ description: Optional untrusted model-authored option description for display.\n+ preview:\n+ type: [\"string\", \"null\"]\n+ description: Optional untrusted model-authored option preview captured for clients.\n \n InterviewQuestionRecord:\n description: Storage shape of an interview question recorded in the event log.\n@@ -11651,4 +11643,4 @@ components:\n login:\n type: string\n description: User's login identifier (e.g. GitHub username).\n- example: octocat\n\\ No newline at end of file\n+ example: octocat\ndiff --git a/lib/crates/fabro-agent/src/lib.rs b/lib/crates/fabro-agent/src/lib.rs\nindex 04a1c6fdf..010004694 100644\n--- a/lib/crates/fabro-agent/src/lib.rs\n+++ b/lib/crates/fabro-agent/src/lib.rs\n@@ -15,6 +15,7 @@ pub mod loop_detection;\n pub mod mcp_integration;\n pub mod memory;\n pub mod profiles;\n+pub mod question_tools;\n pub mod read_before_write_sandbox;\n pub mod sandbox;\n pub mod session;\n@@ -46,6 +47,11 @@ pub use local_sandbox::LocalSandbox;\n pub use loop_detection::detect_loop;\n pub use memory::{MemoryDocument, discover_memory};\n pub use profiles::{AnthropicProfile, EnvContext, GeminiProfile, OpenAiProfile};\n+pub use question_tools::{\n+ ANTHROPIC_ASK_USER_QUESTION_TOOL, AgentQuestion, AgentQuestionAnswer,\n+ AgentQuestionAnswerStatus, AgentQuestionRuntime, AgentToolRuntime,\n+ OPENAI_REQUEST_USER_INPUT_TOOL, register_question_tools,\n+};\n pub use read_before_write_sandbox::ReadBeforeWriteSandbox;\n pub use sandbox::{\n CommandOutputCallback, DirEntry, ExecResult, ExecStreamingResult, GrepOptions, Sandbox,\ndiff --git a/lib/crates/fabro-agent/src/question_tools.rs b/lib/crates/fabro-agent/src/question_tools.rs\nnew file mode 100644\nindex 000000000..fd9c7deda\n--- /dev/null\n+++ b/lib/crates/fabro-agent/src/question_tools.rs\n@@ -0,0 +1,586 @@\n+//! Model-native tools that let a root workflow agent ask the human for input.\n+\n+use std::collections::BTreeMap;\n+use std::future::Future;\n+use std::sync::Arc;\n+\n+use async_trait::async_trait;\n+use fabro_llm::types::ToolDefinition;\n+use fabro_model::AgentProfileKind;\n+use fabro_types::{InterviewOption, QuestionType};\n+use serde::Deserialize;\n+use serde_json::json;\n+use tokio_util::sync::CancellationToken;\n+\n+use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry};\n+\n+tokio::task_local! {\n+ static CURRENT_AGENT_TOOL_RUNTIME: AgentToolRuntime;\n+}\n+\n+pub const OPENAI_REQUEST_USER_INPUT_TOOL: &str = \"request_user_input\";\n+pub const ANTHROPIC_ASK_USER_QUESTION_TOOL: &str = \"AskUserQuestion\";\n+\n+pub const OPTION_DESCRIPTION_MAX_CHARS: usize = 2_000;\n+pub const OPTION_PREVIEW_MAX_CHARS: usize = 4_000;\n+\n+#[derive(Clone, Default)]\n+pub struct AgentToolRuntime {\n+ question_runtime: Option>,\n+}\n+\n+impl AgentToolRuntime {\n+ #[must_use]\n+ pub fn new() -> Self {\n+ Self::default()\n+ }\n+\n+ #[must_use]\n+ pub fn with_question_runtime(runtime: Arc) -> Self {\n+ Self {\n+ question_runtime: Some(runtime),\n+ }\n+ }\n+\n+ #[must_use]\n+ pub fn question_runtime(&self) -> Option> {\n+ self.question_runtime.clone()\n+ }\n+}\n+\n+pub async fn scope_agent_tool_runtime(runtime: AgentToolRuntime, future: F) -> F::Output\n+where\n+ F: Future,\n+{\n+ CURRENT_AGENT_TOOL_RUNTIME.scope(runtime, future).await\n+}\n+\n+fn current_agent_tool_runtime() -> AgentToolRuntime {\n+ CURRENT_AGENT_TOOL_RUNTIME\n+ .try_with(Clone::clone)\n+ .unwrap_or_default()\n+}\n+\n+#[derive(Debug, Clone, PartialEq, Eq)]\n+pub struct AgentQuestion {\n+ pub original_id: Option,\n+ pub original_question: String,\n+ pub header: Option,\n+ pub text: String,\n+ pub question_type: QuestionType,\n+ pub options: Vec,\n+ pub allow_freeform: bool,\n+}\n+\n+#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n+pub enum AgentQuestionAnswerStatus {\n+ Answered,\n+ Cancelled,\n+ Interrupted,\n+ Skipped,\n+ Timeout,\n+}\n+\n+#[derive(Debug, Clone, PartialEq, Eq)]\n+pub struct AgentQuestionAnswer {\n+ pub original_id: Option,\n+ pub original_question: String,\n+ pub answers: Vec,\n+ pub status: AgentQuestionAnswerStatus,\n+}\n+\n+#[async_trait]\n+pub trait AgentQuestionRuntime: Send + Sync {\n+ async fn ask_questions(\n+ &self,\n+ tool_call_id: &str,\n+ questions: Vec,\n+ cancel_token: CancellationToken,\n+ ) -> Result, String>;\n+}\n+\n+#[derive(Debug, Deserialize)]\n+struct OpenAiQuestionToolArgs {\n+ questions: Vec,\n+}\n+\n+#[derive(Debug, Deserialize)]\n+struct OpenAiQuestion {\n+ id: String,\n+ header: String,\n+ question: String,\n+ #[serde(default)]\n+ options: Vec,\n+}\n+\n+#[derive(Debug, Deserialize)]\n+struct OpenAiOption {\n+ label: String,\n+ #[serde(default)]\n+ description: Option,\n+}\n+\n+#[derive(Debug, Deserialize)]\n+struct AnthropicQuestionToolArgs {\n+ questions: Vec,\n+}\n+\n+#[derive(Debug, Deserialize)]\n+#[serde(rename_all = \"camelCase\")]\n+struct AnthropicQuestion {\n+ question: String,\n+ #[serde(default)]\n+ header: Option,\n+ #[serde(default)]\n+ options: Vec,\n+ #[serde(default)]\n+ multi_select: bool,\n+}\n+\n+#[derive(Debug, Deserialize)]\n+struct AnthropicOption {\n+ label: String,\n+ #[serde(default)]\n+ description: Option,\n+ #[serde(default)]\n+ preview: Option,\n+}\n+\n+#[must_use]\n+pub fn is_question_tool(name: &str) -> bool {\n+ matches!(\n+ name,\n+ OPENAI_REQUEST_USER_INPUT_TOOL | ANTHROPIC_ASK_USER_QUESTION_TOOL\n+ )\n+}\n+\n+pub fn register_question_tools(profile_kind: AgentProfileKind, registry: &mut ToolRegistry) {\n+ match profile_kind {\n+ AgentProfileKind::OpenAi => registry.register(make_openai_question_tool()),\n+ AgentProfileKind::Anthropic => registry.register(make_anthropic_question_tool()),\n+ AgentProfileKind::Gemini => {}\n+ }\n+}\n+\n+fn make_openai_question_tool() -> RegisteredTool {\n+ RegisteredTool {\n+ definition: ToolDefinition {\n+ name: OPENAI_REQUEST_USER_INPUT_TOOL.to_string(),\n+ description: \"Ask the human one or more questions and wait for their answers before continuing this stage.\".to_string(),\n+ parameters: json!({\n+ \"type\": \"object\",\n+ \"required\": [\"questions\"],\n+ \"properties\": {\n+ \"questions\": {\n+ \"type\": \"array\",\n+ \"minItems\": 1,\n+ \"items\": {\n+ \"type\": \"object\",\n+ \"required\": [\"id\", \"header\", \"question\", \"options\"],\n+ \"properties\": {\n+ \"id\": { \"type\": \"string\" },\n+ \"header\": { \"type\": \"string\" },\n+ \"question\": { \"type\": \"string\" },\n+ \"options\": {\n+ \"type\": \"array\",\n+ \"items\": {\n+ \"type\": \"object\",\n+ \"required\": [\"label\"],\n+ \"properties\": {\n+ \"label\": { \"type\": \"string\" },\n+ \"description\": { \"type\": \"string\" }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }),\n+ },\n+ executor: Arc::new(|args, ctx| {\n+ Box::pin(async move {\n+ let parsed: OpenAiQuestionToolArgs = parse_tool_args(args)?;\n+ let questions = normalize_openai_questions(parsed)?;\n+ let answers = execute_question_tool(ctx, questions).await?;\n+ format_openai_answers(&answers)\n+ })\n+ }),\n+ }\n+}\n+\n+fn make_anthropic_question_tool() -> RegisteredTool {\n+ RegisteredTool {\n+ definition: ToolDefinition {\n+ name: ANTHROPIC_ASK_USER_QUESTION_TOOL.to_string(),\n+ description: \"Ask the human one or more questions and wait for their answers before continuing this stage.\".to_string(),\n+ parameters: json!({\n+ \"type\": \"object\",\n+ \"required\": [\"questions\"],\n+ \"properties\": {\n+ \"questions\": {\n+ \"type\": \"array\",\n+ \"minItems\": 1,\n+ \"items\": {\n+ \"type\": \"object\",\n+ \"required\": [\"question\", \"options\", \"multiSelect\"],\n+ \"properties\": {\n+ \"question\": { \"type\": \"string\" },\n+ \"header\": { \"type\": \"string\" },\n+ \"options\": {\n+ \"type\": \"array\",\n+ \"items\": {\n+ \"type\": \"object\",\n+ \"required\": [\"label\"],\n+ \"properties\": {\n+ \"label\": { \"type\": \"string\" },\n+ \"description\": { \"type\": \"string\" },\n+ \"preview\": { \"type\": \"string\" }\n+ }\n+ }\n+ },\n+ \"multiSelect\": { \"type\": \"boolean\" }\n+ }\n+ }\n+ }\n+ }\n+ }),\n+ },\n+ executor: Arc::new(|args, ctx| {\n+ Box::pin(async move {\n+ let parsed: AnthropicQuestionToolArgs = parse_tool_args(args)?;\n+ let questions = normalize_anthropic_questions(parsed)?;\n+ let answers = execute_question_tool(ctx, questions).await?;\n+ format_anthropic_answers(&answers)\n+ })\n+ }),\n+ }\n+}\n+\n+fn parse_tool_args Deserialize<'de>>(args: serde_json::Value) -> Result {\n+ serde_json::from_value(args).map_err(|err| format!(\"invalid question tool arguments: {err}\"))\n+}\n+\n+async fn execute_question_tool(\n+ ctx: ToolContext,\n+ questions: Vec,\n+) -> Result, String> {\n+ let session_id = ctx.session_id.as_deref().ok_or_else(|| {\n+ \"human-question tools are available only during a root workflow agent session\".to_string()\n+ })?;\n+ let root_session_id = ctx.root_session_id.as_deref().ok_or_else(|| {\n+ \"human-question tools are available only during a root workflow agent session\".to_string()\n+ })?;\n+ if session_id != root_session_id {\n+ return Err(\n+ \"human-question tools are only available to the root agent; subagents must report back to their parent\".to_string(),\n+ );\n+ }\n+ let tool_call_id = ctx\n+ .tool_call_id\n+ .as_deref()\n+ .ok_or_else(|| \"human-question tool call is missing a provider tool_call_id\".to_string())?;\n+ let runtime = current_agent_tool_runtime().question_runtime().ok_or_else(|| {\n+ \"human-question tools are available only inside a workflow run with an active interviewer\".to_string()\n+ })?;\n+ runtime\n+ .ask_questions(tool_call_id, questions, ctx.cancel.clone())\n+ .await\n+}\n+\n+fn normalize_openai_questions(args: OpenAiQuestionToolArgs) -> Result, String> {\n+ if args.questions.is_empty() {\n+ return Err(\"questions must contain at least one question\".to_string());\n+ }\n+ args.questions\n+ .into_iter()\n+ .map(|question| {\n+ let original_question = question.question.trim().to_string();\n+ Ok(AgentQuestion {\n+ original_id: Some(non_empty(&question.id, \"question id\")?),\n+ text: display_text(Some(question.header.as_str()), &question.question),\n+ header: Some(question.header),\n+ original_question,\n+ question_type: QuestionType::MultipleChoice,\n+ options: options_from_openai(question.options),\n+ allow_freeform: true,\n+ })\n+ })\n+ .collect()\n+}\n+\n+fn normalize_anthropic_questions(\n+ args: AnthropicQuestionToolArgs,\n+) -> Result, String> {\n+ if args.questions.is_empty() {\n+ return Err(\"questions must contain at least one question\".to_string());\n+ }\n+ args.questions\n+ .into_iter()\n+ .map(|question| {\n+ let original_question = non_empty(&question.question, \"question\")?;\n+ Ok(AgentQuestion {\n+ original_id: None,\n+ text: display_text(question.header.as_deref(), &question.question),\n+ header: question.header,\n+ original_question,\n+ question_type: if question.multi_select {\n+ QuestionType::MultiSelect\n+ } else {\n+ QuestionType::MultipleChoice\n+ },\n+ options: options_from_anthropic(question.options),\n+ allow_freeform: true,\n+ })\n+ })\n+ .collect()\n+}\n+\n+fn options_from_openai(options: Vec) -> Vec {\n+ options\n+ .into_iter()\n+ .enumerate()\n+ .map(|(idx, option)| InterviewOption {\n+ key: option_key(idx),\n+ label: option.label,\n+ description: option\n+ .description\n+ .map(|value| bounded_display_field(&value, OPTION_DESCRIPTION_MAX_CHARS)),\n+ preview: None,\n+ })\n+ .collect()\n+}\n+\n+fn options_from_anthropic(options: Vec) -> Vec {\n+ options\n+ .into_iter()\n+ .enumerate()\n+ .map(|(idx, option)| InterviewOption {\n+ key: option_key(idx),\n+ label: option.label,\n+ description: option\n+ .description\n+ .map(|value| bounded_display_field(&value, OPTION_DESCRIPTION_MAX_CHARS)),\n+ preview: option\n+ .preview\n+ .map(|value| bounded_display_field(&value, OPTION_PREVIEW_MAX_CHARS)),\n+ })\n+ .collect()\n+}\n+\n+fn option_key(idx: usize) -> String {\n+ format!(\"option_{}\", idx + 1)\n+}\n+\n+fn non_empty(value: &str, field: &str) -> Result {\n+ let trimmed = value.trim();\n+ if trimmed.is_empty() {\n+ Err(format!(\"{field} must not be empty\"))\n+ } else {\n+ Ok(trimmed.to_string())\n+ }\n+}\n+\n+fn display_text(header: Option<&str>, question: &str) -> String {\n+ let header = header.map(str::trim).filter(|value| !value.is_empty());\n+ let question = question.trim();\n+ match (header, question.is_empty()) {\n+ (Some(header), false) => format!(\"{header}\\n\\n{question}\"),\n+ (Some(header), true) => header.to_string(),\n+ (None, false) => question.to_string(),\n+ (None, true) => String::new(),\n+ }\n+}\n+\n+#[must_use]\n+pub fn bounded_display_field(value: &str, max_chars: usize) -> String {\n+ if value.chars().count() <= max_chars {\n+ return value.to_string();\n+ }\n+ value.chars().take(max_chars).collect()\n+}\n+\n+fn ensure_all_answered(answers: &[AgentQuestionAnswer]) -> Result<(), String> {\n+ if let Some(answer) = answers\n+ .iter()\n+ .find(|answer| answer.status != AgentQuestionAnswerStatus::Answered)\n+ {\n+ return Err(format!(\n+ \"human-question request ended before the user answered `{}`: {}\",\n+ answer.original_question,\n+ answer_status_label(answer.status)\n+ ));\n+ }\n+ Ok(())\n+}\n+\n+fn answer_status_label(status: AgentQuestionAnswerStatus) -> &'static str {\n+ match status {\n+ AgentQuestionAnswerStatus::Answered => \"answered\",\n+ AgentQuestionAnswerStatus::Cancelled => \"cancelled\",\n+ AgentQuestionAnswerStatus::Interrupted => \"interrupted\",\n+ AgentQuestionAnswerStatus::Skipped => \"skipped\",\n+ AgentQuestionAnswerStatus::Timeout => \"timed out\",\n+ }\n+}\n+\n+fn format_openai_answers(answers: &[AgentQuestionAnswer]) -> Result {\n+ ensure_all_answered(answers)?;\n+ let mut answer_map = BTreeMap::new();\n+ for answer in answers {\n+ let Some(original_id) = answer.original_id.as_ref() else {\n+ return Err(\n+ \"OpenAI question answer is missing the original model question id\".to_string(),\n+ );\n+ };\n+ answer_map.insert(original_id.clone(), json!({ \"answers\": answer.answers }));\n+ }\n+ serde_json::to_string(&json!({ \"answers\": answer_map }))\n+ .map_err(|err| format!(\"failed to serialize answers: {err}\"))\n+}\n+\n+fn format_anthropic_answers(answers: &[AgentQuestionAnswer]) -> Result {\n+ ensure_all_answered(answers)?;\n+ let pairs = answers\n+ .iter()\n+ .map(|answer| {\n+ let question = serde_json::to_string(&answer.original_question)\n+ .unwrap_or_else(|_| \"\\\"question\\\"\".to_string());\n+ let answer_text = serde_json::to_string(&answer.answers.join(\", \"))\n+ .unwrap_or_else(|_| \"\\\"\\\"\".to_string());\n+ format!(\"{question}={answer_text}\")\n+ })\n+ .collect::>()\n+ .join(\", \");\n+ Ok(format!(\n+ \"User has answered your questions: {pairs}. You can now continue with the task.\"\n+ ))\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+\n+ fn answered(\n+ original_id: Option<&str>,\n+ question: &str,\n+ answers: &[&str],\n+ ) -> AgentQuestionAnswer {\n+ AgentQuestionAnswer {\n+ original_id: original_id.map(str::to_string),\n+ original_question: question.to_string(),\n+ answers: answers.iter().map(|value| (*value).to_string()).collect(),\n+ status: AgentQuestionAnswerStatus::Answered,\n+ }\n+ }\n+\n+ #[test]\n+ fn openai_request_with_descriptions_normalizes_to_multiple_choice() {\n+ let args: OpenAiQuestionToolArgs = serde_json::from_value(json!({\n+ \"questions\": [{\n+ \"id\": \"q1\",\n+ \"header\": \"Decision\",\n+ \"question\": \"Which path?\",\n+ \"options\": [{ \"label\": \"Ship\", \"description\": \"Deploy now\" }]\n+ }]\n+ }))\n+ .unwrap();\n+\n+ let questions = normalize_openai_questions(args).unwrap();\n+\n+ assert_eq!(questions.len(), 1);\n+ assert_eq!(questions[0].original_id.as_deref(), Some(\"q1\"));\n+ assert_eq!(questions[0].question_type, QuestionType::MultipleChoice);\n+ assert!(questions[0].allow_freeform);\n+ assert_eq!(questions[0].text, \"Decision\\n\\nWhich path?\");\n+ assert_eq!(questions[0].options[0].key, \"option_1\");\n+ assert_eq!(questions[0].options[0].label, \"Ship\");\n+ assert_eq!(\n+ questions[0].options[0].description.as_deref(),\n+ Some(\"Deploy now\")\n+ );\n+ }\n+\n+ #[test]\n+ fn anthropic_multiselect_preserves_preview_and_formats_comma_joined_answers() {\n+ let args: AnthropicQuestionToolArgs = serde_json::from_value(json!({\n+ \"questions\": [{\n+ \"header\": \"Pick features\",\n+ \"question\": \"Which features?\",\n+ \"multiSelect\": true,\n+ \"options\": [{\n+ \"label\": \"Auth\",\n+ \"description\": \"Login support\",\n+ \"preview\": \"auth diff\"\n+ }]\n+ }]\n+ }))\n+ .unwrap();\n+\n+ let questions = normalize_anthropic_questions(args).unwrap();\n+\n+ assert_eq!(questions[0].question_type, QuestionType::MultiSelect);\n+ assert_eq!(\n+ questions[0].options[0].preview.as_deref(),\n+ Some(\"auth diff\")\n+ );\n+ let text =\n+ format_anthropic_answers(&[answered(None, \"Which features?\", &[\"Auth\", \"Billing\"])])\n+ .unwrap();\n+ assert!(text.contains(\"\\\"Which features?\\\"=\\\"Auth, Billing\\\"\"));\n+ }\n+\n+ #[test]\n+ fn openai_answers_are_keyed_by_original_model_question_id() {\n+ let text = format_openai_answers(&[\n+ answered(Some(\"first\"), \"First?\", &[\"Yes\"]),\n+ answered(Some(\"second\"), \"Second?\", &[\"No\"]),\n+ ])\n+ .unwrap();\n+\n+ assert_eq!(\n+ serde_json::from_str::(&text).unwrap(),\n+ json!({\n+ \"answers\": {\n+ \"first\": { \"answers\": [\"Yes\"] },\n+ \"second\": { \"answers\": [\"No\"] }\n+ }\n+ })\n+ );\n+ }\n+\n+ #[test]\n+ fn option_description_and_preview_are_bounded() {\n+ let long = \"x\".repeat(OPTION_PREVIEW_MAX_CHARS + 10);\n+\n+ assert_eq!(\n+ bounded_display_field(&long, OPTION_DESCRIPTION_MAX_CHARS)\n+ .chars()\n+ .count(),\n+ OPTION_DESCRIPTION_MAX_CHARS\n+ );\n+ assert_eq!(\n+ bounded_display_field(&long, OPTION_PREVIEW_MAX_CHARS)\n+ .chars()\n+ .count(),\n+ OPTION_PREVIEW_MAX_CHARS\n+ );\n+ }\n+\n+ #[test]\n+ fn question_tool_registration_is_profile_specific() {\n+ let mut openai = ToolRegistry::new();\n+ register_question_tools(AgentProfileKind::OpenAi, &mut openai);\n+ assert!(openai.get(OPENAI_REQUEST_USER_INPUT_TOOL).is_some());\n+ assert!(openai.get(ANTHROPIC_ASK_USER_QUESTION_TOOL).is_none());\n+\n+ let mut anthropic = ToolRegistry::new();\n+ register_question_tools(AgentProfileKind::Anthropic, &mut anthropic);\n+ assert!(anthropic.get(ANTHROPIC_ASK_USER_QUESTION_TOOL).is_some());\n+ assert!(anthropic.get(OPENAI_REQUEST_USER_INPUT_TOOL).is_none());\n+\n+ let mut gemini = ToolRegistry::new();\n+ register_question_tools(AgentProfileKind::Gemini, &mut gemini);\n+ assert!(gemini.names().is_empty());\n+ }\n+}\ndiff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs\nindex 2d0d0b4e8..0d4fd749f 100644\n--- a/lib/crates/fabro-agent/src/session.rs\n+++ b/lib/crates/fabro-agent/src/session.rs\n@@ -32,6 +32,7 @@ use crate::history::History;\n use crate::loop_detection::detect_loop;\n use crate::memory::{BUDGET_BYTES, MemoryDocument, discover_memory};\n use crate::profiles::EnvContext;\n+use crate::question_tools::AgentToolRuntime;\n use crate::sandbox::Sandbox;\n use crate::skills::{\n ExpandedInput, Skill, default_skill_dirs, discover_skills, expand_skill, make_use_skill_tool,\n@@ -1119,6 +1120,15 @@ impl Session {\n }\n \n pub async fn process_input(&mut self, input: &str) -> Result<(), Error> {\n+ self.process_input_with_runtime(input, AgentToolRuntime::default())\n+ .await\n+ }\n+\n+ pub async fn process_input_with_runtime(\n+ &mut self,\n+ input: &str,\n+ agent_tool_runtime: AgentToolRuntime,\n+ ) -> Result<(), Error> {\n if self.state == SessionState::Closed {\n return Err(Error::SessionClosed);\n }\n@@ -1142,7 +1152,7 @@ impl Session {\n });\n \n // Process the initial input, then drain any followups\n- let mut result = self.run_single_input(input).await;\n+ let mut result = self.run_single_input(input, &agent_tool_runtime).await;\n \n if result.is_ok() {\n loop {\n@@ -1152,7 +1162,7 @@ impl Session {\n .expect(\"followup queue lock poisoned\")\n .pop_front();\n let Some(followup) = followup else { break };\n- result = self.run_single_input(&followup).await;\n+ result = self.run_single_input(&followup, &agent_tool_runtime).await;\n if result.is_err() {\n break;\n }\n@@ -1172,7 +1182,11 @@ impl Session {\n result\n }\n \n- async fn run_single_input(&mut self, input: &str) -> Result<(), Error> {\n+ async fn run_single_input(\n+ &mut self,\n+ input: &str,\n+ agent_tool_runtime: &AgentToolRuntime,\n+ ) -> Result<(), Error> {\n const STREAM_CONSUME_RETRIES: usize = 3;\n \n if self.state == SessionState::Closed {\n@@ -1623,6 +1637,7 @@ impl Session {\n &self.id,\n &self.root_session_id,\n self.tool_env_provider.as_ref(),\n+ agent_tool_runtime,\n )\n .await;\n composite_watcher.abort();\ndiff --git a/lib/crates/fabro-agent/src/tool_execution.rs b/lib/crates/fabro-agent/src/tool_execution.rs\nindex 939e6a4e9..3015b6147 100644\n--- a/lib/crates/fabro-agent/src/tool_execution.rs\n+++ b/lib/crates/fabro-agent/src/tool_execution.rs\n@@ -7,6 +7,7 @@ use tracing::debug;\n \n use crate::config::{SessionOptions, ToolHookCallback, ToolHookDecision};\n use crate::event::{Emitter, SessionBoundEmitter};\n+use crate::question_tools::{self, AgentToolRuntime, is_question_tool};\n use crate::sandbox::Sandbox;\n use crate::session::ToolEnvProvider;\n use crate::tool_registry::{AgentEventEmitter, RegisteredTool, ToolContext, ToolRegistry};\n@@ -31,7 +32,25 @@ pub async fn execute_tool_calls(\n session_id: &str,\n root_session_id: &str,\n tool_env_provider: Option<&Arc>,\n+ agent_tool_runtime: &AgentToolRuntime,\n ) -> Vec {\n+ if tool_calls.iter().any(|tc| is_question_tool(&tc.name)) {\n+ return execute_question_tool_round(\n+ tool_calls,\n+ registry,\n+ env,\n+ tool_hooks,\n+ cancel_token,\n+ config,\n+ emitter,\n+ session_id,\n+ root_session_id,\n+ tool_env_provider,\n+ agent_tool_runtime,\n+ )\n+ .await;\n+ }\n+\n if parallel && tool_calls.len() > 1 {\n execute_tool_calls_parallel(\n tool_calls,\n@@ -44,6 +63,7 @@ pub async fn execute_tool_calls(\n session_id,\n root_session_id,\n tool_env_provider,\n+ agent_tool_runtime,\n )\n .await\n } else {\n@@ -58,6 +78,7 @@ pub async fn execute_tool_calls(\n session_id,\n root_session_id,\n tool_env_provider,\n+ agent_tool_runtime,\n )\n .await\n }\n@@ -78,6 +99,7 @@ async fn execute_tool_calls_sequential(\n session_id: &str,\n root_session_id: &str,\n tool_env_provider: Option<&Arc>,\n+ agent_tool_runtime: &AgentToolRuntime,\n ) -> Vec {\n let mut results = Vec::new();\n for tc in tool_calls {\n@@ -86,7 +108,7 @@ async fn execute_tool_calls_sequential(\n continue;\n }\n \n- let result = execute_and_emit_one_tool(\n+ let result = execute_and_emit_one_tool_with_runtime(\n tc,\n registry,\n env.clone(),\n@@ -97,6 +119,7 @@ async fn execute_tool_calls_sequential(\n session_id,\n root_session_id,\n tool_env_provider,\n+ agent_tool_runtime,\n )\n .await;\n results.push(result);\n@@ -119,8 +142,10 @@ async fn execute_tool_calls_parallel(\n session_id: &str,\n root_session_id: &str,\n tool_env_provider: Option<&Arc>,\n+ agent_tool_runtime: &AgentToolRuntime,\n ) -> Vec {\n let tool_env_provider = tool_env_provider.cloned();\n+ let agent_tool_runtime = agent_tool_runtime.clone();\n let futures: Vec<_> = tool_calls\n .iter()\n .map(|tc| {\n@@ -133,6 +158,7 @@ async fn execute_tool_calls_parallel(\n let root_session_id = root_session_id.to_owned();\n let tool_hooks = tool_hooks.cloned();\n let tool_env_provider = tool_env_provider.clone();\n+ let agent_tool_runtime = agent_tool_runtime.clone();\n let access_denial = config.tool_access_denial_reason(&tc.name);\n // Look up the tool before spawning since ToolRegistry is not Send.\n let registered_tool = if access_denial.is_none() {\n@@ -153,6 +179,7 @@ async fn execute_tool_calls_parallel(\n &session_id,\n &root_session_id,\n tool_env_provider.as_ref(),\n+ &agent_tool_runtime,\n )\n .await\n }\n@@ -162,6 +189,99 @@ async fn execute_tool_calls_parallel(\n future::join_all(futures).await\n }\n \n+#[allow(\n+ clippy::too_many_arguments,\n+ reason = \"Question-tool round handling needs the same execution context as normal tool dispatch.\"\n+)]\n+async fn execute_question_tool_round(\n+ tool_calls: &[ToolCall],\n+ registry: &ToolRegistry,\n+ env: Arc,\n+ tool_hooks: Option<&Arc>,\n+ cancel_token: &CancellationToken,\n+ config: &SessionOptions,\n+ emitter: &Emitter,\n+ session_id: &str,\n+ root_session_id: &str,\n+ tool_env_provider: Option<&Arc>,\n+ agent_tool_runtime: &AgentToolRuntime,\n+) -> Vec {\n+ let first_question_index = tool_calls\n+ .iter()\n+ .position(|tc| is_question_tool(&tc.name))\n+ .expect(\"question-tool round should contain a question tool\");\n+ let mut results = Vec::with_capacity(tool_calls.len());\n+\n+ for (index, tc) in tool_calls.iter().enumerate() {\n+ if cancel_token.is_cancelled() {\n+ results.push(ToolResult::error(tc.id.clone(), \"Cancelled\"));\n+ continue;\n+ }\n+\n+ if index == first_question_index {\n+ results.push(\n+ execute_and_emit_one_tool_with_runtime(\n+ tc,\n+ registry,\n+ env.clone(),\n+ tool_hooks,\n+ cancel_token.child_token(),\n+ config,\n+ emitter,\n+ session_id,\n+ root_session_id,\n+ tool_env_provider,\n+ agent_tool_runtime,\n+ )\n+ .await,\n+ );\n+ } else if is_question_tool(&tc.name) {\n+ results.push(error_tool_result_with_events(\n+ tc,\n+ emitter,\n+ session_id,\n+ config,\n+ \"Only one human-question tool call may be used in a tool round. Combine all questions into a single questions[] batch and call the question tool once.\",\n+ ));\n+ } else {\n+ results.push(error_tool_result_with_events(\n+ tc,\n+ emitter,\n+ session_id,\n+ config,\n+ \"This tool call was not executed because human-question tools must run alone in a tool round. Retry non-question tools in a later round after the user answers.\",\n+ ));\n+ }\n+ }\n+\n+ results\n+}\n+\n+fn error_tool_result_with_events(\n+ tc: &ToolCall,\n+ emitter: &Emitter,\n+ session_id: &str,\n+ config: &SessionOptions,\n+ message: &str,\n+) -> ToolResult {\n+ emitter.emit(session_id.to_owned(), AgentEvent::ToolCallStarted {\n+ tool_name: tc.name.clone(),\n+ tool_call_id: tc.id.clone(),\n+ arguments: tc.arguments.clone(),\n+ });\n+ let result = ToolResult::error(&tc.id, message);\n+ emitter.emit(session_id.to_owned(), AgentEvent::ToolCallOutputDelta {\n+ delta: result.content.to_string(),\n+ });\n+ emitter.emit(session_id.to_owned(), AgentEvent::ToolCallCompleted {\n+ tool_name: tc.name.clone(),\n+ tool_call_id: tc.id.clone(),\n+ output: result.content.clone(),\n+ is_error: true,\n+ });\n+ truncate_tool_result(&result, &tc.name, config)\n+}\n+\n /// Execute a single tool call with event emission and output truncation.\n #[allow(\n clippy::too_many_arguments,\n@@ -178,6 +298,39 @@ pub async fn execute_and_emit_one_tool(\n session_id: &str,\n root_session_id: &str,\n tool_env_provider: Option<&Arc>,\n+) -> ToolResult {\n+ execute_and_emit_one_tool_with_runtime(\n+ tc,\n+ registry,\n+ env,\n+ tool_hooks,\n+ cancel_token,\n+ config,\n+ emitter,\n+ session_id,\n+ root_session_id,\n+ tool_env_provider,\n+ &AgentToolRuntime::default(),\n+ )\n+ .await\n+}\n+\n+#[allow(\n+ clippy::too_many_arguments,\n+ reason = \"Single-tool execution needs the tool, runtime handles, and emission context.\"\n+)]\n+async fn execute_and_emit_one_tool_with_runtime(\n+ tc: &ToolCall,\n+ registry: &ToolRegistry,\n+ env: Arc,\n+ tool_hooks: Option<&Arc>,\n+ cancel_token: CancellationToken,\n+ config: &SessionOptions,\n+ emitter: &Emitter,\n+ session_id: &str,\n+ root_session_id: &str,\n+ tool_env_provider: Option<&Arc>,\n+ agent_tool_runtime: &AgentToolRuntime,\n ) -> ToolResult {\n let access_denial = config.tool_access_denial_reason(&tc.name);\n let registered_tool = if access_denial.is_none() {\n@@ -197,6 +350,7 @@ pub async fn execute_and_emit_one_tool(\n session_id,\n root_session_id,\n tool_env_provider,\n+ agent_tool_runtime,\n )\n .await\n }\n@@ -219,6 +373,7 @@ async fn execute_and_emit_one_tool_with_lookup(\n session_id: &str,\n root_session_id: &str,\n tool_env_provider: Option<&Arc>,\n+ agent_tool_runtime: &AgentToolRuntime,\n ) -> ToolResult {\n emitter.emit(session_id.to_owned(), AgentEvent::ToolCallStarted {\n tool_name: tc.name.clone(),\n@@ -276,6 +431,7 @@ async fn execute_and_emit_one_tool_with_lookup(\n session_id,\n root_session_id,\n tool_env_provider,\n+ agent_tool_runtime,\n )\n .await;\n \n@@ -329,6 +485,7 @@ async fn execute_one_tool(\n session_id: &str,\n root_session_id: &str,\n tool_env_provider: Option<&Arc>,\n+ agent_tool_runtime: &AgentToolRuntime,\n ) -> ToolResult {\n match registered_tool {\n Some(tool) => {\n@@ -355,7 +512,10 @@ async fn execute_one_tool(\n tool_call_id: Some(tc.id.clone()),\n agent_event_emitter,\n };\n- match (tool.executor)(tc.arguments.clone(), ctx).await {\n+ let execution = (tool.executor)(tc.arguments.clone(), ctx);\n+ match question_tools::scope_agent_tool_runtime(agent_tool_runtime.clone(), execution)\n+ .await\n+ {\n Ok(output) => ToolResult::success(&tc.id, serde_json::json!(output)),\n Err(err) => ToolResult::error(&tc.id, err),\n }\n@@ -418,9 +578,11 @@ pub fn validate_tool_args(\n #[cfg(test)]\n mod tests {\n use std::collections::HashMap;\n- use std::sync::Mutex;\n+ use std::sync::{Arc, Mutex};\n \n+ use async_trait::async_trait;\n use fabro_llm::types::{ToolCall, ToolDefinition};\n+ use fabro_model::AgentProfileKind;\n \n use super::*;\n use crate::config::{\n@@ -428,6 +590,10 @@ mod tests {\n };\n use crate::event::Emitter;\n use crate::local_sandbox::LocalSandbox;\n+ use crate::question_tools::{\n+ AgentQuestion, AgentQuestionAnswer, AgentQuestionAnswerStatus, AgentQuestionRuntime,\n+ AgentToolRuntime, register_question_tools,\n+ };\n use crate::read_before_write_sandbox::ReadBeforeWriteSandbox;\n use crate::test_support::MutableMockSandbox;\n use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry};\n@@ -505,6 +671,125 @@ mod tests {\n }\n }\n \n+ struct StubQuestionRuntime;\n+\n+ #[async_trait]\n+ impl AgentQuestionRuntime for StubQuestionRuntime {\n+ async fn ask_questions(\n+ &self,\n+ _tool_call_id: &str,\n+ questions: Vec,\n+ _cancel_token: CancellationToken,\n+ ) -> Result, String> {\n+ Ok(questions\n+ .into_iter()\n+ .map(|question| AgentQuestionAnswer {\n+ original_id: question.original_id,\n+ original_question: question.original_question,\n+ answers: vec![\"Ship\".to_string()],\n+ status: AgentQuestionAnswerStatus::Answered,\n+ })\n+ .collect())\n+ }\n+ }\n+\n+ #[tokio::test]\n+ async fn question_tool_round_rejects_non_question_peers_and_preserves_order() {\n+ let mut registry = ToolRegistry::new();\n+ register_question_tools(AgentProfileKind::OpenAi, &mut registry);\n+ registry.register(make_echo_tool());\n+ let tool_calls = vec![\n+ make_tool_call(\n+ \"request_user_input\",\n+ \"call_question\",\n+ serde_json::json!({\n+ \"questions\": [{\n+ \"id\": \"q1\",\n+ \"header\": \"Decision\",\n+ \"question\": \"Ship it?\",\n+ \"options\": [{ \"label\": \"Ship\" }]\n+ }]\n+ }),\n+ ),\n+ make_tool_call(\"echo\", \"call_echo\", serde_json::json!({\"text\": \"hello\"})),\n+ ];\n+ let runtime = AgentToolRuntime::with_question_runtime(Arc::new(StubQuestionRuntime));\n+\n+ let results = execute_tool_calls(\n+ &tool_calls,\n+ true,\n+ ®istry,\n+ Arc::new(LocalSandbox::new(std::env::current_dir().unwrap())),\n+ None,\n+ &CancellationToken::new(),\n+ &SessionOptions::default(),\n+ &Emitter::new(),\n+ \"root\",\n+ \"root\",\n+ None,\n+ &runtime,\n+ )\n+ .await;\n+\n+ assert_eq!(results.len(), 2);\n+ assert_eq!(results[0].tool_call_id, \"call_question\");\n+ assert!(!results[0].is_error);\n+ assert_eq!(results[1].tool_call_id, \"call_echo\");\n+ assert!(results[1].is_error);\n+ assert!(\n+ results[1]\n+ .content\n+ .as_str()\n+ .unwrap()\n+ .contains(\"human-question tools must run alone\")\n+ );\n+ }\n+\n+ #[tokio::test]\n+ async fn multiple_question_tool_calls_execute_only_first() {\n+ let mut registry = ToolRegistry::new();\n+ register_question_tools(AgentProfileKind::OpenAi, &mut registry);\n+ let question_args = serde_json::json!({\n+ \"questions\": [{\n+ \"id\": \"q1\",\n+ \"header\": \"Decision\",\n+ \"question\": \"Ship it?\",\n+ \"options\": [{ \"label\": \"Ship\" }]\n+ }]\n+ });\n+ let tool_calls = vec![\n+ make_tool_call(\"request_user_input\", \"call_first\", question_args.clone()),\n+ make_tool_call(\"request_user_input\", \"call_second\", question_args),\n+ ];\n+ let runtime = AgentToolRuntime::with_question_runtime(Arc::new(StubQuestionRuntime));\n+\n+ let results = execute_tool_calls(\n+ &tool_calls,\n+ true,\n+ ®istry,\n+ Arc::new(LocalSandbox::new(std::env::current_dir().unwrap())),\n+ None,\n+ &CancellationToken::new(),\n+ &SessionOptions::default(),\n+ &Emitter::new(),\n+ \"root\",\n+ \"root\",\n+ None,\n+ &runtime,\n+ )\n+ .await;\n+\n+ assert!(!results[0].is_error);\n+ assert!(results[1].is_error);\n+ assert!(\n+ results[1]\n+ .content\n+ .as_str()\n+ .unwrap()\n+ .contains(\"Combine all questions into a single questions[] batch\")\n+ );\n+ }\n+\n struct MockHookCallback {\n pre_decision: ToolHookDecision,\n post_calls: Arc>>,\ndiff --git a/lib/crates/fabro-api/tests/interview_option_round_trip.rs b/lib/crates/fabro-api/tests/interview_option_round_trip.rs\nindex 40fb51b23..06f4cff82 100644\n--- a/lib/crates/fabro-api/tests/interview_option_round_trip.rs\n+++ b/lib/crates/fabro-api/tests/interview_option_round_trip.rs\n@@ -13,7 +13,9 @@ fn interview_option_reuses_canonical_type() {\n fn interview_option_round_trips_representative_json() {\n let value = json!({\n \"key\": \"approve\",\n- \"label\": \"Approve\"\n+ \"label\": \"Approve\",\n+ \"description\": \"Approve the proposed changes.\",\n+ \"preview\": \"diff --stat output\"\n });\n \n let option: InterviewOption = serde_json::from_value(value.clone()).unwrap();\ndiff --git a/lib/crates/fabro-api/tests/interview_question_record_round_trip.rs b/lib/crates/fabro-api/tests/interview_question_record_round_trip.rs\nindex 8e8f743b6..14338c4b4 100644\n--- a/lib/crates/fabro-api/tests/interview_question_record_round_trip.rs\n+++ b/lib/crates/fabro-api/tests/interview_question_record_round_trip.rs\n@@ -17,7 +17,12 @@ fn interview_question_record_round_trips_representative_json() {\n \"stage\": \"gate\",\n \"question_type\": \"multiple_choice\",\n \"options\": [\n- { \"key\": \"approve\", \"label\": \"Approve\" },\n+ {\n+ \"key\": \"approve\",\n+ \"label\": \"Approve\",\n+ \"description\": \"Deploy now\",\n+ \"preview\": \"deploy --prod\"\n+ },\n { \"key\": \"reject\", \"label\": \"Reject\" }\n ],\n \"allow_freeform\": true,\ndiff --git a/lib/crates/fabro-api/tests/pending_interview_record_round_trip.rs b/lib/crates/fabro-api/tests/pending_interview_record_round_trip.rs\nindex 9ce7c4c9c..dae81fdc3 100644\n--- a/lib/crates/fabro-api/tests/pending_interview_record_round_trip.rs\n+++ b/lib/crates/fabro-api/tests/pending_interview_record_round_trip.rs\n@@ -18,7 +18,12 @@ fn pending_interview_record_round_trips_populated_question() {\n \"stage\": \"gate\",\n \"question_type\": \"multiple_choice\",\n \"options\": [\n- { \"key\": \"approve\", \"label\": \"Approve\" },\n+ {\n+ \"key\": \"approve\",\n+ \"label\": \"Approve\",\n+ \"description\": \"Deploy now\",\n+ \"preview\": \"deploy --prod\"\n+ },\n { \"key\": \"reject\", \"label\": \"Reject\" }\n ],\n \"allow_freeform\": true,\ndiff --git a/lib/crates/fabro-api/tests/run_projection_round_trip.rs b/lib/crates/fabro-api/tests/run_projection_round_trip.rs\nindex 00aeb7ae2..64a00df91 100644\n--- a/lib/crates/fabro-api/tests/run_projection_round_trip.rs\n+++ b/lib/crates/fabro-api/tests/run_projection_round_trip.rs\n@@ -54,7 +54,12 @@ fn run_projection_round_trips_populated_projection() {\n \"stage\": \"gate\",\n \"question_type\": \"multiple_choice\",\n \"options\": [\n- { \"key\": \"approve\", \"label\": \"Approve\" },\n+ {\n+ \"key\": \"approve\",\n+ \"label\": \"Approve\",\n+ \"description\": \"Deploy now\",\n+ \"preview\": \"deploy --prod\"\n+ },\n { \"key\": \"reject\", \"label\": \"Reject\" }\n ],\n \"allow_freeform\": true,\ndiff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs\nindex d66249aad..c8ad2fff2 100644\n--- a/lib/crates/fabro-cli/src/commands/run/attach.rs\n+++ b/lib/crates/fabro-cli/src/commands/run/attach.rs\n@@ -426,8 +426,10 @@ fn api_question_to_question(question: &types::ApiQuestion) -> Question {\n .options\n .iter()\n .map(|option| InterviewOption {\n- key: option.key.clone(),\n- label: option.label.clone(),\n+ key: option.key.clone(),\n+ label: option.label.clone(),\n+ description: option.description.clone(),\n+ preview: option.preview.clone(),\n })\n .collect();\n converted.allow_freeform = question.allow_freeform;\n@@ -966,8 +968,10 @@ mod tests {\n fn invalid_multiple_choice_without_freeform_is_user_correctable() {\n let mut question = Question::new(\"Pick one.\", QuestionType::MultipleChoice);\n question.options = vec![InterviewOption {\n- key: \"A\".to_string(),\n- label: \"Approve\".to_string(),\n+ key: \"A\".to_string(),\n+ label: \"Approve\".to_string(),\n+ description: None,\n+ preview: None,\n }];\n \n let response = parse_choice_response(&question, PromptRead::Line(\"bogus\".to_string()));\n@@ -979,8 +983,10 @@ mod tests {\n fn unmatched_multiple_choice_with_freeform_remains_text() {\n let mut question = Question::new(\"Pick one.\", QuestionType::MultipleChoice);\n question.options = vec![InterviewOption {\n- key: \"A\".to_string(),\n- label: \"Approve\".to_string(),\n+ key: \"A\".to_string(),\n+ label: \"Approve\".to_string(),\n+ description: None,\n+ preview: None,\n }];\n question.allow_freeform = true;\n \n@@ -1000,12 +1006,16 @@ mod tests {\n let mut question = Question::new(\"Pick many.\", QuestionType::MultiSelect);\n question.options = vec![\n InterviewOption {\n- key: \"A\".to_string(),\n- label: \"Approve\".to_string(),\n+ key: \"A\".to_string(),\n+ label: \"Approve\".to_string(),\n+ description: None,\n+ preview: None,\n },\n InterviewOption {\n- key: \"N\".to_string(),\n- label: \"Notify\".to_string(),\n+ key: \"N\".to_string(),\n+ label: \"Notify\".to_string(),\n+ description: None,\n+ preview: None,\n },\n ];\n \ndiff --git a/lib/crates/fabro-interview/src/auto_approve.rs b/lib/crates/fabro-interview/src/auto_approve.rs\nindex faeba9a7b..66a323548 100644\n--- a/lib/crates/fabro-interview/src/auto_approve.rs\n+++ b/lib/crates/fabro-interview/src/auto_approve.rs\n@@ -72,12 +72,16 @@ mod tests {\n let mut q = Question::new(\"Choose:\", QuestionType::MultipleChoice);\n q.options = vec![\n InterviewOption {\n- key: \"A\".to_string(),\n- label: \"Alpha\".to_string(),\n+ key: \"A\".to_string(),\n+ label: \"Alpha\".to_string(),\n+ description: None,\n+ preview: None,\n },\n InterviewOption {\n- key: \"B\".to_string(),\n- label: \"Beta\".to_string(),\n+ key: \"B\".to_string(),\n+ label: \"Beta\".to_string(),\n+ description: None,\n+ preview: None,\n },\n ];\n let answer = interviewer.ask(q).await.answer;\n@@ -85,8 +89,10 @@ mod tests {\n assert_eq!(\n answer.selected_option,\n Some(InterviewOption {\n- key: \"A\".to_string(),\n- label: \"Alpha\".to_string(),\n+ key: \"A\".to_string(),\n+ label: \"Alpha\".to_string(),\n+ description: None,\n+ preview: None,\n })\n );\n }\ndiff --git a/lib/crates/fabro-interview/src/console.rs b/lib/crates/fabro-interview/src/console.rs\nindex 437025041..0005b1aca 100644\n--- a/lib/crates/fabro-interview/src/console.rs\n+++ b/lib/crates/fabro-interview/src/console.rs\n@@ -296,12 +296,16 @@ mod tests {\n fn find_matching_option_by_key() {\n let options = vec![\n InterviewOption {\n- key: \"A\".to_string(),\n- label: \"Approve\".to_string(),\n+ key: \"A\".to_string(),\n+ label: \"Approve\".to_string(),\n+ description: None,\n+ preview: None,\n },\n InterviewOption {\n- key: \"R\".to_string(),\n- label: \"Reject\".to_string(),\n+ key: \"R\".to_string(),\n+ label: \"Reject\".to_string(),\n+ description: None,\n+ preview: None,\n },\n ];\n let result = find_matching_option(\"A\", &options);\n@@ -313,8 +317,10 @@ mod tests {\n #[test]\n fn find_matching_option_by_key_case_insensitive() {\n let options = vec![InterviewOption {\n- key: \"Y\".to_string(),\n- label: \"Yes\".to_string(),\n+ key: \"Y\".to_string(),\n+ label: \"Yes\".to_string(),\n+ description: None,\n+ preview: None,\n }];\n let result = find_matching_option(\"y\", &options);\n assert!(result.is_some());\n@@ -324,12 +330,16 @@ mod tests {\n fn find_matching_option_by_index() {\n let options = vec![\n InterviewOption {\n- key: \"A\".to_string(),\n- label: \"Alpha\".to_string(),\n+ key: \"A\".to_string(),\n+ label: \"Alpha\".to_string(),\n+ description: None,\n+ preview: None,\n },\n InterviewOption {\n- key: \"B\".to_string(),\n- label: \"Beta\".to_string(),\n+ key: \"B\".to_string(),\n+ label: \"Beta\".to_string(),\n+ description: None,\n+ preview: None,\n },\n ];\n let result = find_matching_option(\"2\", &options);\n@@ -341,8 +351,10 @@ mod tests {\n #[test]\n fn find_matching_option_no_match() {\n let options = vec![InterviewOption {\n- key: \"A\".to_string(),\n- label: \"Alpha\".to_string(),\n+ key: \"A\".to_string(),\n+ label: \"Alpha\".to_string(),\n+ description: None,\n+ preview: None,\n }];\n let result = find_matching_option(\"zzz\", &options);\n assert!(result.is_none());\n@@ -351,8 +363,10 @@ mod tests {\n #[test]\n fn find_matching_option_index_out_of_range() {\n let options = vec![InterviewOption {\n- key: \"A\".to_string(),\n- label: \"Alpha\".to_string(),\n+ key: \"A\".to_string(),\n+ label: \"Alpha\".to_string(),\n+ description: None,\n+ preview: None,\n }];\n let result = find_matching_option(\"5\", &options);\n assert!(result.is_none());\n@@ -362,8 +376,10 @@ mod tests {\n fn non_tty_multiple_choice_eof_returns_interrupted() {\n let mut question = Question::new(\"Approve?\", QuestionType::MultipleChoice);\n question.options = vec![InterviewOption {\n- key: \"A\".to_string(),\n- label: \"Approve\".to_string(),\n+ key: \"A\".to_string(),\n+ label: \"Approve\".to_string(),\n+ description: None,\n+ preview: None,\n }];\n \n let answer = parse_non_tty_choice_response(&question, PromptRead::Eof);\ndiff --git a/lib/crates/fabro-interview/src/lib.rs b/lib/crates/fabro-interview/src/lib.rs\nindex c242cf14a..4c050c4f7 100644\n--- a/lib/crates/fabro-interview/src/lib.rs\n+++ b/lib/crates/fabro-interview/src/lib.rs\n@@ -301,8 +301,10 @@ mod tests {\n #[test]\n fn answer_selected() {\n let opt = InterviewOption {\n- key: \"A\".to_string(),\n- label: \"Approve\".to_string(),\n+ key: \"A\".to_string(),\n+ label: \"Approve\".to_string(),\n+ description: None,\n+ preview: None,\n };\n let a = Answer::selected(\"A\", opt.clone());\n assert_eq!(a.value, AnswerValue::Selected(\"A\".to_string()));\n@@ -319,12 +321,16 @@ mod tests {\n #[test]\n fn question_option_eq() {\n let a = InterviewOption {\n- key: \"Y\".to_string(),\n- label: \"Yes\".to_string(),\n+ key: \"Y\".to_string(),\n+ label: \"Yes\".to_string(),\n+ description: None,\n+ preview: None,\n };\n let b = InterviewOption {\n- key: \"Y\".to_string(),\n- label: \"Yes\".to_string(),\n+ key: \"Y\".to_string(),\n+ label: \"Yes\".to_string(),\n+ description: None,\n+ preview: None,\n };\n assert_eq!(a, b);\n }\ndiff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs\nindex a71d9c748..cf533295b 100644\n--- a/lib/crates/fabro-server/src/demo/mod.rs\n+++ b/lib/crates/fabro-server/src/demo/mod.rs\n@@ -1677,13 +1677,17 @@ mod runs {\n stage: \"review\".into(),\n question_type: QuestionType::YesNo,\n options: vec![\n- ApiQuestionOption {\n- key: \"yes\".into(),\n- label: \"Yes\".into(),\n+ InterviewOption {\n+ key: \"yes\".into(),\n+ label: \"Yes\".into(),\n+ description: None,\n+ preview: None,\n },\n- ApiQuestionOption {\n- key: \"no\".into(),\n- label: \"No\".into(),\n+ InterviewOption {\n+ key: \"no\".into(),\n+ label: \"No\".into(),\n+ description: None,\n+ preview: None,\n },\n ],\n allow_freeform: false,\n@@ -1696,13 +1700,17 @@ mod runs {\n stage: \"migration\".into(),\n question_type: QuestionType::MultipleChoice,\n options: vec![\n- ApiQuestionOption {\n- key: \"incremental\".into(),\n- label: \"Incremental migration\".into(),\n+ InterviewOption {\n+ key: \"incremental\".into(),\n+ label: \"Incremental migration\".into(),\n+ description: None,\n+ preview: None,\n },\n- ApiQuestionOption {\n- key: \"big_bang\".into(),\n- label: \"Big-bang rewrite\".into(),\n+ InterviewOption {\n+ key: \"big_bang\".into(),\n+ label: \"Big-bang rewrite\".into(),\n+ description: None,\n+ preview: None,\n },\n ],\n allow_freeform: true,\ndiff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs\nindex 9bbf93cff..701697a2c 100644\n--- a/lib/crates/fabro-server/src/server.rs\n+++ b/lib/crates/fabro-server/src/server.rs\n@@ -22,10 +22,10 @@ use base64::Engine as _;\n use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;\n use bytes::Bytes;\n pub use fabro_api::types::{\n- AggregateBilling, AggregateBillingTotals, ApiQuestion, ApiQuestionOption, AppendEventResponse,\n- ArtifactEntry, ArtifactListResponse, BillingByModel, BillingStageRef,\n- CloseRunPullRequestResponse, CompletionContentPart, CompletionMessage, CompletionMessageRole,\n- CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest,\n+ AggregateBilling, AggregateBillingTotals, ApiQuestion, AppendEventResponse, ArtifactEntry,\n+ ArtifactListResponse, BillingByModel, BillingStageRef, CloseRunPullRequestResponse,\n+ CompletionContentPart, CompletionMessage, CompletionMessageRole, CompletionResponse,\n+ CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest,\n CreateRunPullRequestRequest, CreateSecretRequest, DeleteRunResponse, DeleteRunSandbox,\n DeleteSecretRequest, DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow, ForkRequest,\n ForkResponse, LinkRunPullRequestRequest, MergeRunPullRequestRequest,\n@@ -2894,14 +2894,7 @@ fn api_question_from_interview_record(question: &InterviewQuestionRecord) -> Api\n text: question.text.clone(),\n stage: question.stage.clone(),\n question_type: question.question_type,\n- options: question\n- .options\n- .iter()\n- .map(|option| ApiQuestionOption {\n- key: option.key.clone(),\n- label: option.label.clone(),\n- })\n- .collect(),\n+ options: question.options.clone(),\n allow_freeform: question.allow_freeform,\n timeout_seconds: question.timeout_seconds,\n context_display: question.context_display.clone(),\ndiff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs\nindex a9ad98fd5..5829dfccc 100644\n--- a/lib/crates/fabro-server/src/server/tests.rs\n+++ b/lib/crates/fabro-server/src/server/tests.rs\n@@ -4861,8 +4861,10 @@ async fn submit_pending_interview_answer_rejects_invalid_answer_shape() {\n stage: \"gate\".to_string(),\n question_type: QuestionType::MultipleChoice,\n options: vec![fabro_types::run_event::InterviewOption {\n- key: \"approve\".to_string(),\n- label: \"Approve\".to_string(),\n+ key: \"approve\".to_string(),\n+ label: \"Approve\".to_string(),\n+ description: None,\n+ preview: None,\n }],\n allow_freeform: false,\n timeout_seconds: None,\n@@ -4948,8 +4950,10 @@ fn answer_from_typed_selected_request_validates_and_attaches_option() {\n stage: \"gate\".to_string(),\n question_type: QuestionType::MultipleChoice,\n options: vec![fabro_types::run_event::InterviewOption {\n- key: \"approve\".to_string(),\n- label: \"Approve\".to_string(),\n+ key: \"approve\".to_string(),\n+ label: \"Approve\".to_string(),\n+ description: None,\n+ preview: None,\n }],\n allow_freeform: false,\n timeout_seconds: None,\n@@ -4979,12 +4983,16 @@ fn answer_from_typed_multi_selected_request_validates_option_keys() {\n question_type: QuestionType::MultiSelect,\n options: vec![\n fabro_types::run_event::InterviewOption {\n- key: \"approve\".to_string(),\n- label: \"Approve\".to_string(),\n+ key: \"approve\".to_string(),\n+ label: \"Approve\".to_string(),\n+ description: None,\n+ preview: None,\n },\n fabro_types::run_event::InterviewOption {\n- key: \"notify\".to_string(),\n- label: \"Notify\".to_string(),\n+ key: \"notify\".to_string(),\n+ label: \"Notify\".to_string(),\n+ description: None,\n+ preview: None,\n },\n ],\n allow_freeform: false,\ndiff --git a/lib/crates/fabro-slack/src/blocks.rs b/lib/crates/fabro-slack/src/blocks.rs\nindex 83a94543b..7632ab97e 100644\n--- a/lib/crates/fabro-slack/src/blocks.rs\n+++ b/lib/crates/fabro-slack/src/blocks.rs\n@@ -153,6 +153,32 @@ fn lead_blocks(question: &Question, run_web_url: Option<&str>) -> Vec {\n blocks\n }\n \n+fn option_descriptions_section(question: &Question) -> Option {\n+ let rows = question\n+ .options\n+ .iter()\n+ .filter_map(|option| {\n+ let description = option.description.as_deref()?.trim();\n+ if description.is_empty() {\n+ return None;\n+ }\n+ Some(format!(\n+ \"• *{}* — {}\",\n+ escape_slack_controls(&option.label),\n+ escape_slack_controls(description)\n+ ))\n+ })\n+ .collect::>();\n+ if rows.is_empty() {\n+ return None;\n+ }\n+ Some(text_block(&truncate_to_limit(\n+ &rows.join(\"\\n\"),\n+ SLACK_SECTION_TEXT_LIMIT,\n+ HEADER_TRUNCATION_SUFFIX,\n+ )))\n+}\n+\n pub fn answered_blocks(question_text: &str, answer_text: &str) -> Vec {\n vec![text_block(&format!(\n \"~{}~\\n*Answer:* {}\",\n@@ -168,6 +194,9 @@ pub fn question_to_blocks(\n run_web_url: Option<&str>,\n ) -> Vec {\n let mut blocks = lead_blocks(question, run_web_url);\n+ if let Some(descriptions) = option_descriptions_section(question) {\n+ blocks.push(descriptions);\n+ }\n \n match question.question_type {\n QuestionType::YesNo | QuestionType::Confirmation => {\n@@ -212,10 +241,26 @@ pub fn question_to_blocks(\n .options\n .iter()\n .map(|opt| {\n- json!({\n+ let mut option = json!({\n \"text\": { \"type\": \"plain_text\", \"text\": opt.label },\n \"value\": opt.key\n- })\n+ });\n+ if let Some(description) = opt\n+ .description\n+ .as_deref()\n+ .map(str::trim)\n+ .filter(|value| !value.is_empty())\n+ {\n+ option[\"description\"] = json!({\n+ \"type\": \"plain_text\",\n+ \"text\": truncate_to_limit(\n+ description,\n+ 75,\n+ HEADER_TRUNCATION_SUFFIX\n+ )\n+ });\n+ }\n+ option\n })\n .collect();\n blocks.push(json!({\n@@ -293,16 +338,22 @@ mod tests {\n let mut q = Question::new(\"Pick a language:\", QuestionType::MultipleChoice);\n q.options = vec![\n InterviewOption {\n- key: \"rs\".to_string(),\n- label: \"Rust\".to_string(),\n+ key: \"rs\".to_string(),\n+ label: \"Rust\".to_string(),\n+ description: None,\n+ preview: None,\n },\n InterviewOption {\n- key: \"ts\".to_string(),\n- label: \"TypeScript\".to_string(),\n+ key: \"ts\".to_string(),\n+ label: \"TypeScript\".to_string(),\n+ description: None,\n+ preview: None,\n },\n InterviewOption {\n- key: \"py\".to_string(),\n- label: \"Python\".to_string(),\n+ key: \"py\".to_string(),\n+ label: \"Python\".to_string(),\n+ description: None,\n+ preview: None,\n },\n ];\n let blocks = question_to_blocks(\"run-1\", \"q-3\", &q, None);\n@@ -566,12 +617,16 @@ mod tests {\n let mut q = Question::new(\"Select features:\", QuestionType::MultiSelect);\n q.options = vec![\n InterviewOption {\n- key: \"a\".to_string(),\n- label: \"Auth\".to_string(),\n+ key: \"a\".to_string(),\n+ label: \"Auth\".to_string(),\n+ description: None,\n+ preview: None,\n },\n InterviewOption {\n- key: \"b\".to_string(),\n- label: \"Billing\".to_string(),\n+ key: \"b\".to_string(),\n+ label: \"Billing\".to_string(),\n+ description: None,\n+ preview: None,\n },\n ];\n let blocks = question_to_blocks(\"run-1\", \"q-5\", &q, None);\n@@ -602,4 +657,22 @@ mod tests {\n .contains(\"\\\"qid\\\":\\\"q-5\\\"\")\n );\n }\n+\n+ #[test]\n+ fn option_descriptions_are_rendered_and_preview_is_not_special_cased() {\n+ let mut q = Question::new(\"Pick one:\", QuestionType::MultipleChoice);\n+ q.options = vec![InterviewOption {\n+ key: \"ship\".to_string(),\n+ label: \"Ship\".to_string(),\n+ description: Some(\"Deploy \".to_string()),\n+ preview: Some(\"preview should not render\".to_string()),\n+ }];\n+\n+ let blocks_value: Value =\n+ serde_json::to_value(question_to_blocks(\"run-1\", \"q-6\", &q, None)).unwrap();\n+ let text = blocks_value.to_string();\n+\n+ assert!(text.contains(\"Deploy <now>\"));\n+ assert!(!text.contains(\"preview should not render\"));\n+ }\n }\ndiff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs\nindex 0da6e957c..246784d40 100644\n--- a/lib/crates/fabro-store/src/run_state.rs\n+++ b/lib/crates/fabro-store/src/run_state.rs\n@@ -1757,12 +1757,16 @@ mod tests {\n question_type: \"multiple_choice\".to_string(),\n options: vec![\n InterviewOption {\n- key: \"approve\".to_string(),\n- label: \"Approve\".to_string(),\n+ key: \"approve\".to_string(),\n+ label: \"Approve\".to_string(),\n+ description: Some(\"Ship it\".to_string()),\n+ preview: Some(\"deploy --prod\".to_string()),\n },\n InterviewOption {\n- key: \"revise\".to_string(),\n- label: \"Revise\".to_string(),\n+ key: \"revise\".to_string(),\n+ label: \"Revise\".to_string(),\n+ description: None,\n+ preview: None,\n },\n ],\n allow_freeform: true,\n@@ -1781,6 +1785,14 @@ mod tests {\n assert_eq!(pending.question.stage, \"gate\");\n assert_eq!(pending.question.question_type, QuestionType::MultipleChoice);\n assert_eq!(pending.question.options.len(), 2);\n+ assert_eq!(\n+ pending.question.options[0].description.as_deref(),\n+ Some(\"Ship it\")\n+ );\n+ assert_eq!(\n+ pending.question.options[0].preview.as_deref(),\n+ Some(\"deploy --prod\")\n+ );\n assert!(pending.question.allow_freeform);\n assert_eq!(pending.question.timeout_seconds, Some(30.0));\n assert_eq!(\ndiff --git a/lib/crates/fabro-types/src/run_event/misc.rs b/lib/crates/fabro-types/src/run_event/misc.rs\nindex b2280c243..3f88cdbd3 100644\n--- a/lib/crates/fabro-types/src/run_event/misc.rs\n+++ b/lib/crates/fabro-types/src/run_event/misc.rs\n@@ -4,10 +4,14 @@ use serde_json::Value;\n use super::ExecOutputTail;\n use crate::{CommandTermination, PullRequestLink};\n \n-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]\n pub struct InterviewOption {\n- pub key: String,\n- pub label: String,\n+ pub key: String,\n+ pub label: String,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub description: Option,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub preview: Option,\n }\n \n #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\ndiff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs\nindex 24feb5650..9bfb742b5 100644\n--- a/lib/crates/fabro-workflow/src/handler/agent.rs\n+++ b/lib/crates/fabro-workflow/src/handler/agent.rs\n@@ -11,6 +11,7 @@ use super::{EngineServices, Handler, NodeTimeoutPolicy};\n use crate::context::{Context, WorkflowContext, keys};\n use crate::error::Error;\n use crate::event::{Emitter, Event, StageScope};\n+use crate::interview_runtime::WorkflowAgentQuestionRuntime;\n use crate::outcome::{\n BilledModelUsage, FailureCategory, FailureDetail, Outcome, OutcomeExt, StageOutcome,\n };\n@@ -27,14 +28,15 @@ pub enum CodergenResult {\n }\n \n pub struct CodergenRunRequest<'a> {\n- pub node: &'a Node,\n- pub prompt: &'a str,\n- pub context: &'a Context,\n- pub thread_id: Option<&'a str>,\n- pub emitter: &'a Arc,\n- pub sandbox: &'a Arc,\n- pub tool_hooks: Option>,\n- pub cancel_token: CancellationToken,\n+ pub node: &'a Node,\n+ pub prompt: &'a str,\n+ pub context: &'a Context,\n+ pub thread_id: Option<&'a str>,\n+ pub emitter: &'a Arc,\n+ pub sandbox: &'a Arc,\n+ pub tool_hooks: Option>,\n+ pub cancel_token: CancellationToken,\n+ pub agent_tool_runtime: fabro_agent::AgentToolRuntime,\n }\n \n pub struct OneShotRequest<'a> {\n@@ -258,6 +260,15 @@ impl Handler for AgentHandler {\n .or_else(|| Some(services.run.provider_id.to_string()));\n let prompt_model = node.model().map(String::from);\n let stage_scope = StageScope::for_handler(context, &node.id);\n+ let agent_tool_runtime = fabro_agent::AgentToolRuntime::with_question_runtime(Arc::new(\n+ WorkflowAgentQuestionRuntime::new(\n+ Arc::clone(&services.interviewer),\n+ Arc::clone(&services.run.emitter),\n+ stage_scope.clone(),\n+ node.id.clone(),\n+ Arc::clone(&services.run.interview_blocker),\n+ ),\n+ ));\n services.run.emitter.emit_scoped(\n &Event::Prompt {\n stage: node.id.clone(),\n@@ -299,6 +310,7 @@ impl Handler for AgentHandler {\n sandbox: &services.run.sandbox,\n tool_hooks,\n cancel_token: services.run.cancel_token(),\n+ agent_tool_runtime: agent_tool_runtime.clone(),\n })\n .await;\n match result {\ndiff --git a/lib/crates/fabro-workflow/src/handler/fan_in.rs b/lib/crates/fabro-workflow/src/handler/fan_in.rs\nindex 455b27b26..3a712ec0f 100644\n--- a/lib/crates/fabro-workflow/src/handler/fan_in.rs\n+++ b/lib/crates/fabro-workflow/src/handler/fan_in.rs\n@@ -269,6 +269,7 @@ async fn llm_evaluate(\n sandbox,\n tool_hooks: None,\n cancel_token,\n+ agent_tool_runtime: fabro_agent::AgentToolRuntime::default(),\n })\n .await\n {\ndiff --git a/lib/crates/fabro-workflow/src/handler/human.rs b/lib/crates/fabro-workflow/src/handler/human.rs\nindex a46a6e525..77b83454a 100644\n--- a/lib/crates/fabro-workflow/src/handler/human.rs\n+++ b/lib/crates/fabro-workflow/src/handler/human.rs\n@@ -1,13 +1,12 @@\n use std::path::Path;\n use std::str::FromStr;\n use std::sync::Arc;\n-use std::sync::atomic::{AtomicUsize, Ordering};\n use std::time::Instant;\n \n use async_trait::async_trait;\n use fabro_graphviz::graph::{Graph, Node};\n use fabro_interview::{Answer, AnswerValue, Interviewer, Question, ask_with_timeout};\n-use fabro_types::{BlockedReason, InterviewOption, Principal, QuestionType, SystemActorKind};\n+use fabro_types::{InterviewOption, Principal, QuestionType, SystemActorKind};\n use ulid::Ulid;\n \n use super::{EngineServices, Handler, NodeTimeoutPolicy};\n@@ -112,8 +111,10 @@ fn build_human_gate_question(\n question.options = choices\n .iter()\n .map(|choice| InterviewOption {\n- key: choice.key.clone(),\n- label: choice.label.clone(),\n+ key: choice.key.clone(),\n+ label: choice.label.clone(),\n+ description: None,\n+ preview: None,\n })\n .collect();\n question.allow_freeform = freeform_target.is_some();\n@@ -138,61 +139,10 @@ fn build_human_gate_question(\n })\n }\n \n-/// Refcount of open interviews for this handler's run. Emits `run.blocked`\n-/// exactly once when the count transitions 0→1, and `run.unblocked` exactly\n-/// once when it transitions back to 0. Internal to `HumanHandler`; shared\n-/// across concurrent `execute` calls fanned out by `ParallelHandler`.\n-struct BlockedStateTracker {\n- unresolved_interviews: AtomicUsize,\n-}\n-\n-impl BlockedStateTracker {\n- fn new() -> Self {\n- Self {\n- unresolved_interviews: AtomicUsize::new(0),\n- }\n- }\n-\n- fn interview_started(&self, emitter: &Emitter) {\n- if self.unresolved_interviews.fetch_add(1, Ordering::AcqRel) == 0 {\n- emitter.emit(&Event::RunBlocked {\n- blocked_reason: BlockedReason::HumanInputRequired,\n- });\n- }\n- }\n-\n- fn interview_resolved(&self, emitter: &Emitter) {\n- // Guard against unmatched resolves (e.g., tests that over-resolve) so\n- // the counter cannot underflow. `compare_exchange_weak` loops until we\n- // either observe zero (and bail) or successfully decrement.\n- let mut current = self.unresolved_interviews.load(Ordering::Acquire);\n- loop {\n- if current == 0 {\n- return;\n- }\n- match self.unresolved_interviews.compare_exchange_weak(\n- current,\n- current - 1,\n- Ordering::AcqRel,\n- Ordering::Acquire,\n- ) {\n- Ok(_) => {\n- if current == 1 {\n- emitter.emit(&Event::RunUnblocked);\n- }\n- return;\n- }\n- Err(observed) => current = observed,\n- }\n- }\n- }\n-}\n-\n /// Blocks until a human selects an option derived from outgoing edges.\n pub struct HumanHandler {\n interviewer: Arc,\n emitter: Option>,\n- tracker: BlockedStateTracker,\n }\n \n impl HumanHandler {\n@@ -200,7 +150,6 @@ impl HumanHandler {\n Self {\n interviewer,\n emitter: None,\n- tracker: BlockedStateTracker::new(),\n }\n }\n \n@@ -295,8 +244,10 @@ impl Handler for HumanHandler {\n .options\n .iter()\n .map(|option| InterviewOption {\n- key: option.key.clone(),\n- label: option.label.clone(),\n+ key: option.key.clone(),\n+ label: option.label.clone(),\n+ description: option.description.clone(),\n+ preview: option.preview.clone(),\n })\n .collect(),\n allow_freeform: question.allow_freeform,\n@@ -305,8 +256,10 @@ impl Handler for HumanHandler {\n },\n &stage_scope,\n );\n- self.tracker\n- .interview_started(services.run.emitter.as_ref());\n+ let interview_guard = services\n+ .run\n+ .interview_blocker\n+ .block(Arc::clone(&services.run.emitter));\n let interview_start = Instant::now();\n let answer_submission = ask_with_timeout(self.interviewer.as_ref(), question).await;\n let answer_actor = answer_submission.actor.clone();\n@@ -327,8 +280,7 @@ impl Handler for HumanHandler {\n },\n &stage_scope,\n );\n- self.tracker\n- .interview_resolved(services.run.emitter.as_ref());\n+ interview_guard.resolve();\n let default_choice = node\n .attrs\n .get(\"human.default_choice\")\n@@ -371,8 +323,7 @@ impl Handler for HumanHandler {\n },\n &stage_scope,\n );\n- self.tracker\n- .interview_resolved(services.run.emitter.as_ref());\n+ interview_guard.resolve();\n return Ok(unanswered_human_gate(\n \"human interaction interrupted before an answer was provided\",\n ));\n@@ -389,8 +340,7 @@ impl Handler for HumanHandler {\n },\n &stage_scope,\n );\n- self.tracker\n- .interview_resolved(services.run.emitter.as_ref());\n+ interview_guard.resolve();\n return Ok(unanswered_human_gate(\"human skipped interaction\"));\n }\n \n@@ -406,8 +356,7 @@ impl Handler for HumanHandler {\n },\n &stage_scope,\n );\n- self.tracker\n- .interview_resolved(services.run.emitter.as_ref());\n+ interview_guard.resolve();\n \n // Try fixed-choice match\n if let Some(selected) = find_choice_match(&answer, &choices) {\n@@ -894,8 +843,10 @@ mod tests {\n async fn wait_human_emits_blocked_then_unblocked_around_interview() {\n let interviewer = Arc::new(CallbackInterviewer::new(|_| {\n Answer::selected(\"A\", InterviewOption {\n- key: \"A\".to_string(),\n- label: \"Approve\".to_string(),\n+ key: \"A\".to_string(),\n+ label: \"Approve\".to_string(),\n+ description: None,\n+ preview: None,\n })\n }));\n let handler = HumanHandler::new(interviewer);\n@@ -1128,9 +1079,10 @@ mod tests {\n \n #[test]\n fn blocked_state_tracker_emits_once_across_parallel_interview_races() {\n- let tracker = BlockedStateTracker::new();\n+ let blocker = Arc::new(crate::interview_runtime::RunInterviewBlocker::new());\n let emitter = Arc::new(Emitter::new(fabro_types::fixtures::RUN_1));\n let event_names = Arc::new(Mutex::new(Vec::new()));\n+ let guards = Arc::new(Mutex::new(Vec::new()));\n \n emitter.on_event({\n let event_names = Arc::clone(&event_names);\n@@ -1148,22 +1100,25 @@ mod tests {\n \n std::thread::scope(|scope| {\n for _ in 0..8 {\n- let tracker = &tracker;\n+ let blocker = Arc::clone(&blocker);\n let emitter = Arc::clone(&emitter);\n- scope.spawn(move || tracker.interview_started(emitter.as_ref()));\n+ let guards = Arc::clone(&guards);\n+ scope.spawn(move || {\n+ guards.lock().unwrap().push(blocker.block(emitter));\n+ });\n }\n });\n \n std::thread::scope(|scope| {\n for _ in 0..8 {\n- let tracker = &tracker;\n- let emitter = Arc::clone(&emitter);\n- scope.spawn(move || tracker.interview_resolved(emitter.as_ref()));\n+ let guards = Arc::clone(&guards);\n+ scope.spawn(move || {\n+ let guard = guards.lock().unwrap().pop().unwrap();\n+ guard.resolve();\n+ });\n }\n });\n \n- tracker.interview_resolved(emitter.as_ref());\n-\n assert_eq!(event_names.lock().unwrap().as_slice(), [\n \"run.blocked\",\n \"run.unblocked\"\ndiff --git a/lib/crates/fabro-workflow/src/handler/llm/acp.rs b/lib/crates/fabro-workflow/src/handler/llm/acp.rs\nindex 40929cc97..316882de6 100644\n--- a/lib/crates/fabro-workflow/src/handler/llm/acp.rs\n+++ b/lib/crates/fabro-workflow/src/handler/llm/acp.rs\n@@ -449,14 +449,15 @@ mod tests {\n let context = Context::new();\n let result = backend\n .run(CodergenRunRequest {\n- node: &node,\n- prompt: \"write hello\",\n- context: &context,\n- thread_id: None,\n- emitter: &emitter,\n- sandbox: &sandbox,\n- tool_hooks: None,\n- cancel_token: CancellationToken::new(),\n+ node: &node,\n+ prompt: \"write hello\",\n+ context: &context,\n+ thread_id: None,\n+ emitter: &emitter,\n+ sandbox: &sandbox,\n+ tool_hooks: None,\n+ cancel_token: CancellationToken::new(),\n+ agent_tool_runtime: fabro_agent::AgentToolRuntime::default(),\n })\n .await\n .unwrap();\n@@ -516,14 +517,15 @@ mod tests {\n let context = Context::new();\n let result = backend\n .run(CodergenRunRequest {\n- node: &node,\n- prompt: \"write hello\",\n- context: &context,\n- thread_id: None,\n- emitter: &emitter,\n- sandbox: &sandbox,\n- tool_hooks: None,\n- cancel_token: CancellationToken::new(),\n+ node: &node,\n+ prompt: \"write hello\",\n+ context: &context,\n+ thread_id: None,\n+ emitter: &emitter,\n+ sandbox: &sandbox,\n+ tool_hooks: None,\n+ cancel_token: CancellationToken::new(),\n+ agent_tool_runtime: fabro_agent::AgentToolRuntime::default(),\n })\n .await\n .unwrap();\n@@ -563,14 +565,15 @@ mod tests {\n let context = Context::new();\n let result = backend\n .run(CodergenRunRequest {\n- node: &node,\n- prompt: \"write hello\",\n- context: &context,\n- thread_id: None,\n- emitter: &emitter,\n- sandbox: &sandbox,\n- tool_hooks: None,\n- cancel_token: CancellationToken::new(),\n+ node: &node,\n+ prompt: \"write hello\",\n+ context: &context,\n+ thread_id: None,\n+ emitter: &emitter,\n+ sandbox: &sandbox,\n+ tool_hooks: None,\n+ cancel_token: CancellationToken::new(),\n+ agent_tool_runtime: fabro_agent::AgentToolRuntime::default(),\n })\n .await\n .unwrap();\n@@ -601,14 +604,15 @@ mod tests {\n let context = Context::new();\n let result = backend\n .run(CodergenRunRequest {\n- node: &node,\n- prompt: \"write hello\",\n- context: &context,\n- thread_id: None,\n- emitter: &emitter,\n- sandbox: &sandbox_dyn,\n- tool_hooks: None,\n- cancel_token: CancellationToken::new(),\n+ node: &node,\n+ prompt: \"write hello\",\n+ context: &context,\n+ thread_id: None,\n+ emitter: &emitter,\n+ sandbox: &sandbox_dyn,\n+ tool_hooks: None,\n+ cancel_token: CancellationToken::new(),\n+ agent_tool_runtime: fabro_agent::AgentToolRuntime::default(),\n })\n .await;\n assert!(result.is_err());\n@@ -650,14 +654,15 @@ mod tests {\n let context = Context::new();\n let result = backend\n .run(CodergenRunRequest {\n- node: &node,\n- prompt: \"cancel\",\n- context: &context,\n- thread_id: None,\n- emitter: &emitter,\n- sandbox: &sandbox,\n- tool_hooks: None,\n- cancel_token: CancellationToken::new(),\n+ node: &node,\n+ prompt: \"cancel\",\n+ context: &context,\n+ thread_id: None,\n+ emitter: &emitter,\n+ sandbox: &sandbox,\n+ tool_hooks: None,\n+ cancel_token: CancellationToken::new(),\n+ agent_tool_runtime: fabro_agent::AgentToolRuntime::default(),\n })\n .await;\n let Err(err) = result else {\n@@ -703,14 +708,15 @@ mod tests {\n let context = Context::new();\n backend\n .run(CodergenRunRequest {\n- node: &node,\n- prompt: \"write hello\",\n- context: &context,\n- thread_id: None,\n- emitter: &emitter,\n- sandbox: &sandbox,\n- tool_hooks: None,\n- cancel_token: CancellationToken::new(),\n+ node: &node,\n+ prompt: \"write hello\",\n+ context: &context,\n+ thread_id: None,\n+ emitter: &emitter,\n+ sandbox: &sandbox,\n+ tool_hooks: None,\n+ cancel_token: CancellationToken::new(),\n+ agent_tool_runtime: fabro_agent::AgentToolRuntime::default(),\n })\n .await\n .unwrap();\n@@ -744,14 +750,15 @@ mod tests {\n let context = Context::new();\n let result = backend\n .run(CodergenRunRequest {\n- node: &node,\n- prompt: \"write hello\",\n- context: &context,\n- thread_id: None,\n- emitter: &emitter,\n- sandbox: &sandbox_dyn,\n- tool_hooks: None,\n- cancel_token: CancellationToken::new(),\n+ node: &node,\n+ prompt: \"write hello\",\n+ context: &context,\n+ thread_id: None,\n+ emitter: &emitter,\n+ sandbox: &sandbox_dyn,\n+ tool_hooks: None,\n+ cancel_token: CancellationToken::new(),\n+ agent_tool_runtime: fabro_agent::AgentToolRuntime::default(),\n })\n .await;\n let Err(err) = result else {\n@@ -796,14 +803,15 @@ mod tests {\n let context = Context::new();\n let result = backend\n .run(CodergenRunRequest {\n- node: &node,\n- prompt: \"write hello\",\n- context: &context,\n- thread_id: None,\n- emitter: &emitter,\n- sandbox: &sandbox_dyn,\n- tool_hooks: None,\n- cancel_token: CancellationToken::new(),\n+ node: &node,\n+ prompt: \"write hello\",\n+ context: &context,\n+ thread_id: None,\n+ emitter: &emitter,\n+ sandbox: &sandbox_dyn,\n+ tool_hooks: None,\n+ cancel_token: CancellationToken::new(),\n+ agent_tool_runtime: fabro_agent::AgentToolRuntime::default(),\n })\n .await;\n let Err(err) = result else {\ndiff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs\nindex 38f20a5d9..41c6ca1aa 100644\n--- a/lib/crates/fabro-workflow/src/handler/llm/api.rs\n+++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs\n@@ -7,7 +7,7 @@ use fabro_agent::tool_registry::{RegisteredTool, ToolContext, ToolRegistry};\n use fabro_agent::{\n AgentEvent, AgentProfile, AnthropicProfile, CompletionCoordinator, GeminiProfile,\n Message as AgentMessage, OpenAiProfile, Sandbox, Session, SessionOptions, StaticEnvProvider,\n- ToolEnvProvider,\n+ ToolEnvProvider, register_question_tools,\n };\n use fabro_auth::{CredentialSource, EnvCredentialSource};\n use fabro_graphviz::graph::{AttrValue, Node};\n@@ -733,6 +733,7 @@ impl AgentApiBackend {\n });\n \n profile.register_subagent_tools(manager, factory, 0);\n+ register_question_tools(provider.profile_kind, profile.tool_registry_mut());\n if let Some(services) = fabro_run_tools {\n register_fabro_run_tools(profile.tool_registry_mut(), &services);\n }\n@@ -971,6 +972,7 @@ impl CodergenBackend for AgentApiBackend {\n let sandbox = request.sandbox;\n let tool_hooks = request.tool_hooks;\n let cancel_token = request.cancel_token;\n+ let agent_tool_runtime = request.agent_tool_runtime;\n \n let fidelity = context.fidelity();\n let reuse_key = if fidelity == Fidelity::Full {\n@@ -1081,7 +1083,9 @@ impl CodergenBackend for AgentApiBackend {\n return Err(err);\n }\n }\n- session.process_input(prompt).await\n+ session\n+ .process_input_with_runtime(prompt, agent_tool_runtime.clone())\n+ .await\n }\n Err(err) => Err(err),\n };\n@@ -1205,7 +1209,10 @@ impl CodergenBackend for AgentApiBackend {\n return Err(err);\n }\n }\n- match session.process_input(prompt).await {\n+ match session\n+ .process_input_with_runtime(prompt, agent_tool_runtime.clone())\n+ .await\n+ {\n Ok(()) => {\n succeeded = true;\n break;\ndiff --git a/lib/crates/fabro-workflow/src/handler/manager_loop.rs b/lib/crates/fabro-workflow/src/handler/manager_loop.rs\nindex cf6fcaf8e..d7e1dff7b 100644\n--- a/lib/crates/fabro-workflow/src/handler/manager_loop.rs\n+++ b/lib/crates/fabro-workflow/src/handler/manager_loop.rs\n@@ -231,6 +231,7 @@ impl Handler for SubWorkflowHandler {\n \n let parent_run = Arc::clone(&services.run);\n let registry = Arc::clone(&services.registry);\n+ let interviewer = Arc::clone(&services.interviewer);\n let base_env = services.base_env.clone();\n let github_token = services.github_token.clone();\n let inputs = services.inputs.clone();\n@@ -269,6 +270,7 @@ impl Handler for SubWorkflowHandler {\n engine: Arc::new(EngineServices {\n run: child_run,\n registry,\n+ interviewer,\n git_state: std::sync::RwLock::new(None),\n base_env,\n github_token,\ndiff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs\nindex 4ec7fbcd8..58cbe93f5 100644\n--- a/lib/crates/fabro-workflow/src/handler/parallel.rs\n+++ b/lib/crates/fabro-workflow/src/handler/parallel.rs\n@@ -304,6 +304,7 @@ impl Handler for ParallelHandler {\n for setup in branch_setups {\n let parent_run = Arc::clone(&services.run);\n let registry = Arc::clone(&services.registry);\n+ let interviewer = Arc::clone(&services.interviewer);\n let base_env = services.base_env.clone();\n let github_token = services.github_token.clone();\n let inputs = services.inputs.clone();\n@@ -375,6 +376,7 @@ impl Handler for ParallelHandler {\n let branch_services = EngineServices {\n run: parent_run.with_sandbox(Arc::clone(&setup.sandbox)),\n registry: Arc::clone(®istry),\n+ interviewer,\n git_state: std::sync::RwLock::new(None),\n base_env: base_env.clone(),\n github_token: github_token.clone(),\ndiff --git a/lib/crates/fabro-workflow/src/interview_runtime.rs b/lib/crates/fabro-workflow/src/interview_runtime.rs\nnew file mode 100644\nindex 000000000..e844de8b3\n--- /dev/null\n+++ b/lib/crates/fabro-workflow/src/interview_runtime.rs\n@@ -0,0 +1,632 @@\n+use std::sync::Arc;\n+use std::sync::atomic::{AtomicUsize, Ordering};\n+use std::time::Instant;\n+\n+use async_trait::async_trait;\n+use fabro_agent::{\n+ AgentQuestion, AgentQuestionAnswer, AgentQuestionAnswerStatus, AgentQuestionRuntime,\n+};\n+use fabro_interview::{Answer, AnswerSubmission, AnswerValue, Interviewer, Question};\n+use fabro_types::{BlockedReason, InterviewOption, Principal, SystemActorKind};\n+use futures::future;\n+use tokio_util::sync::CancellationToken;\n+use ulid::Ulid;\n+\n+use crate::event::{Emitter, Event, StageScope};\n+use crate::millis_u64;\n+\n+/// Run-scoped refcount for unresolved human input. Emits `run.blocked` on the\n+/// first unresolved human/agent interview and `run.unblocked` after the last\n+/// one resolves.\n+pub(crate) struct RunInterviewBlocker {\n+ unresolved_interviews: AtomicUsize,\n+}\n+\n+impl RunInterviewBlocker {\n+ #[must_use]\n+ pub(crate) fn new() -> Self {\n+ Self {\n+ unresolved_interviews: AtomicUsize::new(0),\n+ }\n+ }\n+\n+ pub(crate) fn block(self: &Arc, emitter: Arc) -> RunInterviewGuard {\n+ if self.unresolved_interviews.fetch_add(1, Ordering::AcqRel) == 0 {\n+ emitter.emit(&Event::RunBlocked {\n+ blocked_reason: BlockedReason::HumanInputRequired,\n+ });\n+ }\n+ RunInterviewGuard {\n+ blocker: Arc::clone(self),\n+ emitter,\n+ resolved: false,\n+ }\n+ }\n+\n+ fn resolved(&self, emitter: &Emitter) {\n+ let mut current = self.unresolved_interviews.load(Ordering::Acquire);\n+ loop {\n+ if current == 0 {\n+ return;\n+ }\n+ match self.unresolved_interviews.compare_exchange_weak(\n+ current,\n+ current - 1,\n+ Ordering::AcqRel,\n+ Ordering::Acquire,\n+ ) {\n+ Ok(_) => {\n+ if current == 1 {\n+ emitter.emit(&Event::RunUnblocked);\n+ }\n+ return;\n+ }\n+ Err(observed) => current = observed,\n+ }\n+ }\n+ }\n+}\n+\n+impl Default for RunInterviewBlocker {\n+ fn default() -> Self {\n+ Self::new()\n+ }\n+}\n+\n+pub(crate) struct RunInterviewGuard {\n+ blocker: Arc,\n+ emitter: Arc,\n+ resolved: bool,\n+}\n+\n+impl RunInterviewGuard {\n+ pub(crate) fn resolve(mut self) {\n+ if !self.resolved {\n+ self.blocker.resolved(self.emitter.as_ref());\n+ self.resolved = true;\n+ }\n+ }\n+}\n+\n+impl Drop for RunInterviewGuard {\n+ fn drop(&mut self) {\n+ if !self.resolved {\n+ self.blocker.resolved(self.emitter.as_ref());\n+ self.resolved = true;\n+ }\n+ }\n+}\n+\n+pub(crate) struct WorkflowAgentQuestionRuntime {\n+ interviewer: Arc,\n+ emitter: Arc,\n+ stage_scope: StageScope,\n+ stage_id: String,\n+ blocker: Arc,\n+}\n+\n+impl WorkflowAgentQuestionRuntime {\n+ #[must_use]\n+ pub(crate) fn new(\n+ interviewer: Arc,\n+ emitter: Arc,\n+ stage_scope: StageScope,\n+ stage_id: impl Into,\n+ blocker: Arc,\n+ ) -> Self {\n+ Self {\n+ interviewer,\n+ emitter,\n+ stage_scope,\n+ stage_id: stage_id.into(),\n+ blocker,\n+ }\n+ }\n+}\n+\n+struct PreparedQuestion {\n+ agent_question: AgentQuestion,\n+ question: Question,\n+}\n+\n+struct PendingAgentQuestionBatch {\n+ emitter: Arc,\n+ stage_scope: StageScope,\n+ stage_id: String,\n+ questions: Vec<(String, String)>,\n+ started_at: Instant,\n+ guard: Option,\n+}\n+\n+impl PendingAgentQuestionBatch {\n+ fn new(\n+ emitter: Arc,\n+ stage_scope: StageScope,\n+ stage_id: String,\n+ prepared: &[PreparedQuestion],\n+ guard: RunInterviewGuard,\n+ started_at: Instant,\n+ ) -> Self {\n+ Self {\n+ emitter,\n+ stage_scope,\n+ stage_id,\n+ questions: prepared\n+ .iter()\n+ .map(|prepared_question| {\n+ (\n+ prepared_question.question.id.clone(),\n+ prepared_question.question.text.clone(),\n+ )\n+ })\n+ .collect(),\n+ started_at,\n+ guard: Some(guard),\n+ }\n+ }\n+\n+ fn resolve(mut self) {\n+ if let Some(guard) = self.guard.take() {\n+ guard.resolve();\n+ }\n+ }\n+}\n+\n+impl Drop for PendingAgentQuestionBatch {\n+ fn drop(&mut self) {\n+ if self.guard.is_none() {\n+ return;\n+ }\n+ let duration_ms = millis_u64(self.started_at.elapsed());\n+ for (question_id, question) in &self.questions {\n+ self.emitter.emit_scoped(\n+ &Event::InterviewInterrupted {\n+ actor: Some(Principal::System {\n+ system_kind: SystemActorKind::Engine,\n+ }),\n+ question_id: question_id.clone(),\n+ question: question.clone(),\n+ stage: self.stage_id.clone(),\n+ reason: \"interrupted\".to_string(),\n+ duration_ms,\n+ },\n+ &self.stage_scope,\n+ );\n+ }\n+ if let Some(guard) = self.guard.take() {\n+ guard.resolve();\n+ }\n+ }\n+}\n+\n+#[async_trait]\n+impl AgentQuestionRuntime for WorkflowAgentQuestionRuntime {\n+ async fn ask_questions(\n+ &self,\n+ tool_call_id: &str,\n+ questions: Vec,\n+ cancel_token: CancellationToken,\n+ ) -> Result, String> {\n+ if questions.is_empty() {\n+ return Ok(Vec::new());\n+ }\n+\n+ let prepared = questions\n+ .into_iter()\n+ .enumerate()\n+ .map(|(index, question)| self.prepare_question(tool_call_id, index, question))\n+ .collect::>();\n+\n+ for prepared_question in &prepared {\n+ let question = &prepared_question.question;\n+ self.emitter.emit_scoped(\n+ &Event::InterviewStarted {\n+ question_id: question.id.clone(),\n+ question: question.text.clone(),\n+ stage: self.stage_id.clone(),\n+ question_type: question.question_type.to_string(),\n+ options: question.options.clone(),\n+ allow_freeform: question.allow_freeform,\n+ timeout_seconds: None,\n+ context_display: question.context_display.clone(),\n+ },\n+ &self.stage_scope,\n+ );\n+ }\n+\n+ let interview_start = Instant::now();\n+ let cleanup = PendingAgentQuestionBatch::new(\n+ Arc::clone(&self.emitter),\n+ self.stage_scope.clone(),\n+ self.stage_id.clone(),\n+ &prepared,\n+ self.blocker.block(Arc::clone(&self.emitter)),\n+ interview_start,\n+ );\n+ let ask_all = future::join_all(\n+ prepared\n+ .iter()\n+ .map(|prepared_question| self.interviewer.ask(prepared_question.question.clone())),\n+ );\n+ tokio::pin!(ask_all);\n+\n+ let answers = tokio::select! {\n+ submissions = &mut ask_all => Some(submissions),\n+ () = cancel_token.cancelled() => None,\n+ };\n+\n+ let results = match answers {\n+ Some(submissions) => prepared\n+ .iter()\n+ .zip(submissions)\n+ .map(|(prepared_question, submission)| {\n+ self.emit_submission_event(\n+ prepared_question,\n+ &submission,\n+ millis_u64(interview_start.elapsed()),\n+ );\n+ answer_from_submission(&prepared_question.agent_question, &submission)\n+ })\n+ .collect::>(),\n+ None => prepared\n+ .iter()\n+ .map(|prepared_question| {\n+ self.emit_interrupted(\n+ prepared_question,\n+ Some(Principal::System {\n+ system_kind: SystemActorKind::Engine,\n+ }),\n+ \"interrupted\",\n+ millis_u64(interview_start.elapsed()),\n+ );\n+ AgentQuestionAnswer {\n+ original_id: prepared_question.agent_question.original_id.clone(),\n+ original_question: prepared_question\n+ .agent_question\n+ .original_question\n+ .clone(),\n+ answers: Vec::new(),\n+ status: AgentQuestionAnswerStatus::Interrupted,\n+ }\n+ })\n+ .collect::>(),\n+ };\n+\n+ cleanup.resolve();\n+ Ok(results)\n+ }\n+}\n+\n+impl WorkflowAgentQuestionRuntime {\n+ fn prepare_question(\n+ &self,\n+ tool_call_id: &str,\n+ index: usize,\n+ agent_question: AgentQuestion,\n+ ) -> PreparedQuestion {\n+ let mut question = Question::new(agent_question.text.clone(), agent_question.question_type);\n+ question.id = internal_question_id(&self.stage_scope, tool_call_id, index);\n+ question.options.clone_from(&agent_question.options);\n+ question.allow_freeform = agent_question.allow_freeform;\n+ question.stage.clone_from(&self.stage_id);\n+ question.metadata.insert(\n+ \"agent.tool_call_id\".to_string(),\n+ serde_json::json!(tool_call_id),\n+ );\n+ question.metadata.insert(\n+ \"agent.original_question\".to_string(),\n+ serde_json::json!(agent_question.original_question),\n+ );\n+ if let Some(original_id) = &agent_question.original_id {\n+ question.metadata.insert(\n+ \"agent.original_id\".to_string(),\n+ serde_json::json!(original_id),\n+ );\n+ }\n+ if let Some(header) = &agent_question.header {\n+ question\n+ .metadata\n+ .insert(\"agent.header\".to_string(), serde_json::json!(header));\n+ }\n+ PreparedQuestion {\n+ agent_question,\n+ question,\n+ }\n+ }\n+\n+ fn emit_submission_event(\n+ &self,\n+ prepared: &PreparedQuestion,\n+ submission: &AnswerSubmission,\n+ duration_ms: u64,\n+ ) {\n+ match submission.answer.value {\n+ AnswerValue::Timeout => self.emitter.emit_scoped(\n+ &Event::InterviewTimeout {\n+ actor: Some(Principal::System {\n+ system_kind: SystemActorKind::Timeout,\n+ }),\n+ question_id: prepared.question.id.clone(),\n+ question: prepared.question.text.clone(),\n+ stage: self.stage_id.clone(),\n+ duration_ms,\n+ },\n+ &self.stage_scope,\n+ ),\n+ AnswerValue::Interrupted => self.emit_interrupted(\n+ prepared,\n+ Some(submission.actor.clone()),\n+ \"interrupted\",\n+ duration_ms,\n+ ),\n+ AnswerValue::Cancelled => self.emit_interrupted(\n+ prepared,\n+ Some(submission.actor.clone()),\n+ \"cancelled\",\n+ duration_ms,\n+ ),\n+ _ => self.emitter.emit_scoped(\n+ &Event::InterviewCompleted {\n+ actor: Some(submission.actor.clone()),\n+ question_id: prepared.question.id.clone(),\n+ question: prepared.question.text.clone(),\n+ answer: answer_labels(&prepared.question.options, &submission.answer)\n+ .join(\", \"),\n+ duration_ms,\n+ },\n+ &self.stage_scope,\n+ ),\n+ }\n+ }\n+\n+ fn emit_interrupted(\n+ &self,\n+ prepared: &PreparedQuestion,\n+ actor: Option,\n+ reason: &str,\n+ duration_ms: u64,\n+ ) {\n+ self.emitter.emit_scoped(\n+ &Event::InterviewInterrupted {\n+ actor,\n+ question_id: prepared.question.id.clone(),\n+ question: prepared.question.text.clone(),\n+ stage: self.stage_id.clone(),\n+ reason: reason.to_string(),\n+ duration_ms,\n+ },\n+ &self.stage_scope,\n+ );\n+ }\n+}\n+\n+fn answer_from_submission(\n+ agent_question: &AgentQuestion,\n+ submission: &AnswerSubmission,\n+) -> AgentQuestionAnswer {\n+ let status = match &submission.answer.value {\n+ AnswerValue::Cancelled => AgentQuestionAnswerStatus::Cancelled,\n+ AnswerValue::Interrupted => AgentQuestionAnswerStatus::Interrupted,\n+ AnswerValue::Skipped => AgentQuestionAnswerStatus::Skipped,\n+ AnswerValue::Timeout => AgentQuestionAnswerStatus::Timeout,\n+ _ => AgentQuestionAnswerStatus::Answered,\n+ };\n+ let answers = if status == AgentQuestionAnswerStatus::Answered {\n+ answer_labels(&agent_question.options, &submission.answer)\n+ } else {\n+ Vec::new()\n+ };\n+ AgentQuestionAnswer {\n+ original_id: agent_question.original_id.clone(),\n+ original_question: agent_question.original_question.clone(),\n+ answers,\n+ status,\n+ }\n+}\n+\n+fn answer_labels(options: &[InterviewOption], answer: &Answer) -> Vec {\n+ match &answer.value {\n+ AnswerValue::Selected(key) => {\n+ vec![option_label(options, key, answer.selected_option.as_ref())]\n+ }\n+ AnswerValue::MultiSelected(keys) => keys\n+ .iter()\n+ .map(|key| option_label(options, key, None))\n+ .collect(),\n+ AnswerValue::Text(text) => vec![text.clone()],\n+ AnswerValue::Yes => vec![\"yes\".to_string()],\n+ AnswerValue::No => vec![\"no\".to_string()],\n+ AnswerValue::Cancelled => vec![\"cancelled\".to_string()],\n+ AnswerValue::Interrupted => vec![\"interrupted\".to_string()],\n+ AnswerValue::Skipped => vec![\"skipped\".to_string()],\n+ AnswerValue::Timeout => vec![\"timeout\".to_string()],\n+ }\n+}\n+\n+fn option_label(\n+ options: &[InterviewOption],\n+ key: &str,\n+ selected_option: Option<&InterviewOption>,\n+) -> String {\n+ selected_option\n+ .filter(|option| option.key == key)\n+ .or_else(|| options.iter().find(|option| option.key == key))\n+ .map_or_else(|| key.to_string(), |option| option.label.clone())\n+}\n+\n+fn internal_question_id(scope: &StageScope, tool_call_id: &str, index: usize) -> String {\n+ format!(\n+ \"agentq-{}-v{}-{}-{}-{}\",\n+ slug(&scope.node_id),\n+ scope.visit,\n+ slug(tool_call_id),\n+ index + 1,\n+ Ulid::new()\n+ )\n+}\n+\n+fn slug(value: &str) -> String {\n+ let mut out = value\n+ .chars()\n+ .filter_map(|ch| {\n+ if ch.is_ascii_alphanumeric() {\n+ Some(ch.to_ascii_lowercase())\n+ } else if matches!(ch, '-' | '_') {\n+ Some(ch)\n+ } else {\n+ None\n+ }\n+ })\n+ .take(48)\n+ .collect::();\n+ if out.is_empty() {\n+ out.push('x');\n+ }\n+ out\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use fabro_interview::ControlInterviewer;\n+ use fabro_types::{EventBody, RunId};\n+\n+ use super::*;\n+\n+ #[test]\n+ fn answer_labels_return_user_facing_labels_in_submission_order() {\n+ let options = vec![\n+ InterviewOption {\n+ key: \"a\".to_string(),\n+ label: \"Alpha\".to_string(),\n+ ..InterviewOption::default()\n+ },\n+ InterviewOption {\n+ key: \"b\".to_string(),\n+ label: \"Beta\".to_string(),\n+ ..InterviewOption::default()\n+ },\n+ ];\n+ let answer = Answer::multi_selected(vec![\"b\".to_string(), \"a\".to_string()]);\n+\n+ assert_eq!(answer_labels(&options, &answer), vec![\"Beta\", \"Alpha\"]);\n+ }\n+\n+ #[test]\n+ fn internal_question_id_includes_stage_visit_and_tool_call_context() {\n+ let scope = StageScope {\n+ node_id: \"Review Changes\".to_string(),\n+ visit: 3,\n+ parallel_group_id: None,\n+ parallel_branch_id: None,\n+ };\n+\n+ let id = internal_question_id(&scope, \"call_123\", 1);\n+\n+ assert!(id.starts_with(\"agentq-reviewchanges-v3-call_123-2-\"));\n+ }\n+\n+ #[tokio::test]\n+ async fn batch_questions_are_all_started_before_run_is_blocked_and_return_labels() {\n+ let interviewer = Arc::new(ControlInterviewer::new());\n+ let emitter = Arc::new(Emitter::new(RunId::new()));\n+ let events = Arc::new(std::sync::Mutex::new(Vec::new()));\n+ emitter.on_event({\n+ let events = Arc::clone(&events);\n+ move |event| events.lock().unwrap().push(event.clone())\n+ });\n+ let runtime = WorkflowAgentQuestionRuntime::new(\n+ interviewer.clone(),\n+ Arc::clone(&emitter),\n+ StageScope {\n+ node_id: \"ask\".to_string(),\n+ visit: 1,\n+ parallel_group_id: None,\n+ parallel_branch_id: None,\n+ },\n+ \"ask\",\n+ Arc::new(RunInterviewBlocker::new()),\n+ );\n+ let option = InterviewOption {\n+ key: \"ship\".to_string(),\n+ label: \"Ship it\".to_string(),\n+ description: Some(\"Deploy\".to_string()),\n+ preview: Some(\"preview\".to_string()),\n+ };\n+\n+ let ask = tokio::spawn(async move {\n+ runtime\n+ .ask_questions(\n+ \"call_1\",\n+ vec![\n+ AgentQuestion {\n+ original_id: Some(\"q1\".to_string()),\n+ original_question: \"First?\".to_string(),\n+ header: None,\n+ text: \"First?\".to_string(),\n+ question_type: fabro_types::QuestionType::MultipleChoice,\n+ options: vec![option.clone()],\n+ allow_freeform: true,\n+ },\n+ AgentQuestion {\n+ original_id: Some(\"q2\".to_string()),\n+ original_question: \"Second?\".to_string(),\n+ header: None,\n+ text: \"Second?\".to_string(),\n+ question_type: fabro_types::QuestionType::MultipleChoice,\n+ options: vec![option.clone()],\n+ allow_freeform: true,\n+ },\n+ ],\n+ CancellationToken::new(),\n+ )\n+ .await\n+ .unwrap()\n+ });\n+\n+ tokio::task::yield_now().await;\n+ let question_ids = {\n+ let events = events.lock().unwrap();\n+ assert!(matches!(events[0].body, EventBody::InterviewStarted(_)));\n+ assert!(matches!(events[1].body, EventBody::InterviewStarted(_)));\n+ assert!(matches!(events[2].body, EventBody::RunBlocked(_)));\n+ events\n+ .iter()\n+ .filter_map(|event| match &event.body {\n+ EventBody::InterviewStarted(props) => Some(props.question_id.clone()),\n+ _ => None,\n+ })\n+ .collect::>()\n+ };\n+\n+ for question_id in question_ids {\n+ let option = InterviewOption {\n+ key: \"ship\".to_string(),\n+ label: \"Ship it\".to_string(),\n+ ..InterviewOption::default()\n+ };\n+ interviewer\n+ .submit(\n+ &question_id,\n+ AnswerSubmission::system(\n+ Answer::selected(\"ship\", option),\n+ SystemActorKind::Engine,\n+ ),\n+ )\n+ .await\n+ .unwrap();\n+ }\n+\n+ let answers = ask.await.unwrap();\n+\n+ assert_eq!(answers.len(), 2);\n+ assert_eq!(answers[0].answers, vec![\"Ship it\"]);\n+ assert_eq!(answers[1].answers, vec![\"Ship it\"]);\n+ assert!(\n+ events\n+ .lock()\n+ .unwrap()\n+ .iter()\n+ .any(|event| matches!(event.body, EventBody::RunUnblocked(_)))\n+ );\n+ }\n+}\ndiff --git a/lib/crates/fabro-workflow/src/lib.rs b/lib/crates/fabro-workflow/src/lib.rs\nindex 194a0ca2c..42f5c7f43 100644\n--- a/lib/crates/fabro-workflow/src/lib.rs\n+++ b/lib/crates/fabro-workflow/src/lib.rs\n@@ -298,6 +298,7 @@ pub mod github_token_source;\n pub(crate) mod graph;\n pub mod handler;\n mod hook_context;\n+mod interview_runtime;\n #[allow(\n dead_code,\n reason = \"The lifecycle module remains crate-visible for tests and pending integrations.\"\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs\nindex 953f39d2d..3165f7855 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs\n@@ -675,6 +675,7 @@ pub async fn initialize(\n let engine = Arc::new(EngineServices {\n run: Arc::clone(&run_services),\n registry,\n+ interviewer: Arc::clone(&options.interviewer),\n git_state: std::sync::RwLock::new(None),\n base_env,\n github_token,\ndiff --git a/lib/crates/fabro-workflow/src/services.rs b/lib/crates/fabro-workflow/src/services.rs\nindex 72c5499f9..9210998d3 100644\n--- a/lib/crates/fabro-workflow/src/services.rs\n+++ b/lib/crates/fabro-workflow/src/services.rs\n@@ -9,6 +9,7 @@ use fabro_auth::CredentialSource;\n #[cfg(test)]\n use fabro_auth::ResolvedCredentials;\n use fabro_hooks::{HookContext, HookDecision, HookExecutionContext, HookRunner};\n+use fabro_interview::Interviewer;\n use fabro_model::{Catalog, ProviderId};\n use fabro_types::{ManifestPath, RunId};\n use tokio_util::sync::CancellationToken;\n@@ -16,6 +17,7 @@ use tokio_util::sync::CancellationToken;\n use crate::event::Emitter;\n use crate::github_token_source::GitHubTokenSource;\n use crate::handler::HandlerRegistry;\n+use crate::interview_runtime::RunInterviewBlocker;\n use crate::run_metadata::{RunMetadataRuntime, RunMetadataWriterHandle};\n use crate::runtime_store::RunStoreHandle;\n use crate::sandbox_git::GitState;\n@@ -91,19 +93,20 @@ pub struct FabroRunToolServices {\n /// does NOT count as cancellation.\n #[derive(Clone)]\n pub struct RunServices {\n- pub run_store: RunStoreHandle,\n- pub emitter: Arc,\n- pub sandbox: Arc,\n- pub hook_runner: Option>,\n- pub locations: RunLocations,\n- pub(crate) cancel_token: CancellationToken,\n- pub provider_id: ProviderId,\n- pub model: String,\n- pub llm_source: Arc,\n- pub catalog: Arc,\n- pub(crate) sandbox_git: Arc,\n- pub(crate) metadata_runtime: Arc,\n- pub(crate) metadata_writer: Option,\n+ pub run_store: RunStoreHandle,\n+ pub emitter: Arc,\n+ pub sandbox: Arc,\n+ pub hook_runner: Option>,\n+ pub locations: RunLocations,\n+ pub(crate) cancel_token: CancellationToken,\n+ pub provider_id: ProviderId,\n+ pub model: String,\n+ pub llm_source: Arc,\n+ pub catalog: Arc,\n+ pub(crate) sandbox_git: Arc,\n+ pub(crate) metadata_runtime: Arc,\n+ pub(crate) metadata_writer: Option,\n+ pub(crate) interview_blocker: Arc,\n }\n \n impl RunServices {\n@@ -137,6 +140,7 @@ impl RunServices {\n sandbox_git,\n metadata_runtime,\n metadata_writer,\n+ interview_blocker: Arc::new(RunInterviewBlocker::new()),\n })\n }\n \n@@ -224,6 +228,7 @@ impl RunServices {\n pub struct EngineServices {\n pub run: Arc,\n pub registry: Arc,\n+ pub interviewer: Arc,\n /// Git state for the current run. Set via `set_git_state` at the start of\n /// `execute` and read by parallel/fan-in handlers.\n pub(crate) git_state: std::sync::RwLock