
ASk_User
Prompt
Suggest a clean rewrite of this file. Are there any code issues, perf wins, ways to reduce LOC? Do not use stdlib or any external deps. only use the existing helpers. //! ask_user β pause and ask the user 1-4 single-select questions in one blocking //! modal, then return every answer together. Each question is a page (header tab + //! prompt + options, each option carrying a REQUIRED one-line description) with an //! optional write-your-own row; the user answers each (or Skips it) and submits once. //! //! THREADING: execute BLOCKS on ctx.elicit (the UI-thread seam) for the whole time //! the modal is open β same shape as manage_conversation's mutator hop, but the wait //! spans many frames. Null seam (test/standalone/headless) β the tool reports that //! interactive prompts are unavailable rather than hanging. const h = @import("../helpers/helpers.zig"); const spec = @import("spec.zig"); const Allocator = h.Allocator; const fail = spec.ToolResult.fail; const schema = \\{"type":"object","required":["questions"],"additionalProperties":false, \\"properties":{ \\"questions":{"type":"array","minItems":1,"maxItems":8,"description":"1-8 questions, shown one page at a time in a single blocking dialog; all answers return together as one JSON object keyed by each question's header.","items":{ \\"type":"object","required":["header","question","options"],"additionalProperties":false,"properties":{ \\"header":{"type":"string","description":"Short tab label (<=16 chars) AND the key this question's answer is returned under. Make each header distinct."}, \\"question":{"type":"string","description":"The full question shown to the user."}, \\"options":{"type":"array","minItems":2,"maxItems":6,"description":"The selectable answers (single-select).","items":{ \\"type":"object","required":["label","description"],"additionalProperties":false,"properties":{ \\"label":{"type":"string","description":"Short answer label."}, \\"description":{"type":"string","description":"One line explaining this option. REQUIRED and non-empty."}}}}, \\"allow_custom":{"type":"boolean","description":"Offer a write-your-own row (default true)."}}}}}} ; pub const tool = spec.ToolSpec{ .name = "ask_user", .description = "Ask the user 1-8 single-select questions in ONE blocking dialog and get every answer back at once β batch every question you need into this single call rather than calling it more than once. Each question has a short header (the answer key), a prompt, and 2-6 options that each REQUIRE a one-line description; an optional write-your-own row lets the user type free text. FULLY BLOCKING, no timeout: the turn cannot proceed until the user answers or dismisses it, so use it only when you genuinely cannot proceed without the user's decision and can't infer, look up, or reasonably assume the answer yourself. Not for open-ended questions β those go in your response text; this tool is choice-only. Don't call it if the user has said they're away, busy, or otherwise unavailable β it will simply hang until they return. Returns a JSON object keyed by header; a skipped question returns \"user skipped question\".", .input_schema = schema, .execute = execute, }; /// fieldStr, unescaping only when needed. Both the raw slice (into input_json) and the /// unescaped copy (arena) outlive the blocking call β input_json + the tool arena are /// alive until execute returns, which is after the user has answered. fn strField(a: Allocator, obj: []const u8, key: []const u8) ?[]const u8 { const raw = h.fieldStr(obj, key) orelse return null; for (raw) |c| if (c == '\\') return h.unescape(a, raw) catch raw; return raw; } fn execute(a: Allocator, input_json: []const u8, ctx: *const spec.ToolCtx) spec.ToolResult { const elicitor = ctx.elicit orelse return fail("ask_user: interactive prompts are unavailable in this context"); const oom = fail("ask_user: oom"); const qs_raw = h.fieldRaw(input_json, "questions") orelse return fail("ask_user: missing 'questions'"); var questions: h.Vec(spec.ElicitQuestion) = .empty; var it = h.arrayIter(qs_raw); while (it.next()) |qe| { if (questions.items.len >= spec.elicit_max_questions) break; const header = strField(a, qe, "header") orelse return fail("ask_user: each question needs a 'header'"); const prompt = strField(a, qe, "question") orelse return fail("ask_user: each question needs a 'question'"); const opts_raw = h.fieldRaw(qe, "options") orelse return fail("ask_user: each question needs 'options'"); var options: h.Vec(spec.ElicitOption) = .empty; var oit = h.arrayIter(opts_raw); while (oit.next()) |oe| { const label = strField(a, oe, "label") orelse return fail("ask_user: each option needs a 'label'"); const desc = strField(a, oe, "description") orelse return fail("ask_user: each option needs a 'description' (required)"); if (h.trimWs(desc).len == 0) return fail("ask_user: option 'description' must be non-empty"); options.append(a, .{ .label = label, .description = desc }) catch return oom; } if (options.items.len < 2) return fail("ask_user: each question needs at least 2 options"); const allow_custom = h.fieldBool(qe, "allow_custom") orelse true; questions.append(a, .{ .header = header, .prompt = prompt, .options = options.items, .allow_custom = allow_custom, }) catch return oom; } if (questions.items.len == 0) return fail("ask_user: 'questions' must have at least one entry"); var req = spec.ElicitRequest{ .questions = questions.items }; var out = spec.ElicitAnswers{}; elicitor.ask(&req, &out); // BLOCKS until the user submits or dismisses the modal // ββ model-facing result: one JSON object keyed by header (label / text / skip / dismiss) ββ var w = h.JsonWriter.init(a); _ = w.objectBegin(); for (questions.items, 0..) |q, i| { const ans = if (out.dismissed) "user dismissed the prompt" else out.get(i); _ = w.key(q.header).string(ans); } _ = w.objectEnd(); // ββ UI-facing record: the full prompt + each selection, for the transcript recap and // the read-only receipt. A dismissed prompt renders as every question skipped. ββ const render_json = buildRenderJson(a, questions.items, &out); return .{ .ok = true, .output_claude = w.slice(), .render_json = render_json }; } fn buildRenderJson(a: Allocator, questions: []const spec.ElicitQuestion, out: *const spec.ElicitAnswers) []const u8 { var w = h.JsonWriter.init(a); _ = w.objectBegin(); if (out.dismissed) _ = w.key("dismissed").boolean(true); _ = w.key("questions").arrayBegin(); for (questions, 0..) |q, i| { _ = w.objectBegin(); _ = w.key("header").string(q.header); _ = w.key("prompt").string(q.prompt); _ = w.key("options").arrayBegin(); for (q.options) |o| { _ = w.objectBegin(); _ = w.key("label").string(o.label); _ = w.key("description").string(o.description); _ = w.objectEnd(); } _ = w.arrayEnd(); // dismissed β uniformly skipped; otherwise the recorded selection kind. if (out.dismissed or out.sels[i] == .skipped) { _ = w.key("sel").string("skipped"); } else switch (out.sels[i]) { .option => _ = w.key("sel").string("option").key("index").uint(out.opt_idx[i]), .custom => _ = w.key("sel").string("custom").key("text").string(out.get(i)), .skipped => unreachable, } _ = w.objectEnd(); } _ = w.arrayEnd(); _ = w.objectEnd(); return w.slice(); }
Response not available