const std = @import("std"); const question_answer = @import("../../core/agent/question_answer.zig"); const core_types = @import("../../core/shared/types.zig"); const text_utils = @import("../../core/shared/text_utils.zig"); const tool_dispatch = @import("../../core/tooling/tool_dispatch.zig"); const Allocator = std.mem.Allocator; const whitespace = " \t\r\n"; pub const cancel_sentinel = "(user cancelled the question)"; pub const not_available_sentinel = "(ask_user_question is only available in the interactive shell; ask the user freeform instead)"; const invalid_args_sentinel = "(ask_user_question: invalid arguments; provide {questions})"; const QuestionBatchParseError = Allocator.Error || error{ InvalidArguments, MissingQuestions, QuestionsNotArray, QuestionCount, QuestionNotObject, MissingQuestionText, QuestionTextNotString, EmptyQuestionText, MissingOptions, OptionsNotArray, OptionCount, OptionNotObject, MissingOptionLabel, OptionLabelNotString, EmptyOptionLabel, DuplicateOptionLabel, }; pub const Input = struct { args_json: []u8, pub fn deinit(self: *Input, alloc: Allocator) void { alloc.free(self.args_json); self.* = .{ .args_json = &.{} }; } }; pub const Requester = struct { ctx: ?*anyopaque, response_alloc: Allocator, request: tool_dispatch.AskQuestionBatchFn, }; pub fn decode(ctx: tool_dispatch.DispatchContext, args_json: []const u8) tool_dispatch.DispatchError!tool_dispatch.DecodeResult { const input = try ctx.allocator.create(Input); errdefer ctx.allocator.destroy(input); input.* = .{ .args_json = try ctx.allocator.dupe(u8, args_json) }; errdefer input.deinit(ctx.allocator); return .{ .input = .{ .ptr = input, .deinit_fn = inputDeinit } }; } fn inputDeinit(ptr: *anyopaque, alloc: Allocator) void { const input: *Input = @ptrCast(@alignCast(ptr)); input.deinit(alloc); alloc.destroy(input); } pub fn validate(_: tool_dispatch.DispatchContext, _: tool_dispatch.ToolInput) tool_dispatch.DispatchError!?[]u8 { return null; } pub fn call(ctx: tool_dispatch.DispatchContext, erased: tool_dispatch.ToolInput) tool_dispatch.DispatchError!tool_dispatch.ToolResult { const request = ctx.ask_question_batch orelse { return .{ .success = try ctx.allocator.dupe(u8, not_available_sentinel) }; }; const input = erased.as(Input); const output = executeWithRequester(ctx.allocator, input.args_json, .{ .ctx = ctx.ask_question_ctx, .response_alloc = ctx.allocator, .request = request, }) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return .{ .failure = try std.fmt.allocPrint(ctx.allocator, "ask_user_question failed: {s}", .{@errorName(err)}) }, }; return .{ .success = output }; } pub fn executeWithRequester(result_alloc: Allocator, args_json: []const u8, requester: Requester) ![]u8 { var parse_arena_state = std.heap.ArenaAllocator.init(result_alloc); defer parse_arena_state.deinit(); const parse_arena = parse_arena_state.allocator(); const entries = parseQuestionBatch(parse_arena, args_json) catch |err| return invalidArgsBody(result_alloc, err); const answers_opt = try requester.request(requester.ctx, requester.response_alloc, entries); if (answers_opt) |answers| { defer freeAnswers(requester.response_alloc, answers); return question_answer.encodeJson(result_alloc, entries, answers); } return result_alloc.dupe(u8, cancel_sentinel); } fn parseQuestionBatch(arena: Allocator, args_json: []const u8) QuestionBatchParseError![]const core_types.QuestionBatchEntry { var parsed = std.json.parseFromSlice(std.json.Value, arena, args_json, .{}) catch { return error.InvalidArguments; }; defer parsed.deinit(); if (parsed.value != .object) return error.InvalidArguments; const args = parsed.value.object; const questions_value = args.get("questions") orelse return error.MissingQuestions; if (questions_value != .array) return error.QuestionsNotArray; const questions_arr = questions_value.array; if (questions_arr.items.len < 1 or questions_arr.items.len > 4) return error.QuestionCount; var entries_buf: std.ArrayList(core_types.QuestionBatchEntry) = .empty; try entries_buf.ensureTotalCapacity(arena, questions_arr.items.len); for (questions_arr.items) |q_item| { if (q_item != .object) return error.QuestionNotObject; const question_value = q_item.object.get("question") orelse return error.MissingQuestionText; if (question_value != .string) return error.QuestionTextNotString; const question_trimmed = std.mem.trim(u8, question_value.string, whitespace); if (question_trimmed.len == 0) return error.EmptyQuestionText; const question_safe = try dupeTerminalSafeQuestionText(arena, question_trimmed); const options_value = q_item.object.get("options") orelse return error.MissingOptions; if (options_value != .array) return error.OptionsNotArray; const options_arr = options_value.array; if (options_arr.items.len < 2 or options_arr.items.len > 6) return error.OptionCount; var options_buf: std.ArrayList(core_types.QuestionOption) = .empty; try options_buf.ensureTotalCapacity(arena, options_arr.items.len); for (options_arr.items) |item| { if (item != .object) return error.OptionNotObject; const label_value = item.object.get("label") orelse return error.MissingOptionLabel; if (label_value != .string) return error.OptionLabelNotString; const label_trimmed = std.mem.trim(u8, label_value.string, whitespace); if (label_trimmed.len == 0) return error.EmptyOptionLabel; const label_safe = try dupeTerminalSafeQuestionText(arena, label_trimmed); for (options_buf.items) |existing| { if (std.ascii.eqlIgnoreCase(existing.label, label_safe)) return error.DuplicateOptionLabel; } var desc_slice: ?[]const u8 = null; if (item.object.get("description")) |desc_value| { if (desc_value == .string) { const trimmed_desc = std.mem.trim(u8, desc_value.string, whitespace); if (trimmed_desc.len > 0) desc_slice = try dupeTerminalSafeQuestionText(arena, trimmed_desc); } } options_buf.appendAssumeCapacity(.{ .label = label_safe, .description = desc_slice, }); } entries_buf.appendAssumeCapacity(.{ .question = question_safe, .options = try options_buf.toOwnedSlice(arena), }); } return entries_buf.toOwnedSlice(arena); } fn dupeTerminalSafeQuestionText(arena: Allocator, text: []const u8) Allocator.Error![]const u8 { if (text_utils.isTerminalSafe(text)) return arena.dupe(u8, text); const encoded = try text_utils.encodeTerminalSafe(arena, text, std.math.maxInt(usize)); return encoded.bytes; } fn invalidArgsBody(alloc: Allocator, err: QuestionBatchParseError) Allocator.Error![]u8 { const body = switch (err) { error.OutOfMemory => return error.OutOfMemory, error.InvalidArguments => invalid_args_sentinel, error.MissingQuestions => "(ask_user_question: missing required array \"questions\")", error.QuestionsNotArray => "(ask_user_question: \"questions\" must be an array)", error.QuestionCount => "(ask_user_question: provide 1 to 4 questions)", error.QuestionNotObject => "(ask_user_question: each question must be an object with a \"question\" and \"options\")", error.MissingQuestionText => "(ask_user_question: each question requires a \"question\" string)", error.QuestionTextNotString => "(ask_user_question: question \"question\" must be a string)", error.EmptyQuestionText => "(ask_user_question: question text must not be empty)", error.MissingOptions => "(ask_user_question: each question requires an \"options\" array)", error.OptionsNotArray => "(ask_user_question: \"options\" must be an array)", error.OptionCount => "(ask_user_question: provide 2 to 6 options per question)", error.OptionNotObject => "(ask_user_question: each option must be an object with a \"label\")", error.MissingOptionLabel => "(ask_user_question: each option requires a \"label\" string)", error.OptionLabelNotString => "(ask_user_question: option \"label\" must be a string)", error.EmptyOptionLabel => "(ask_user_question: option labels must not be empty)", error.DuplicateOptionLabel => "(ask_user_question: option labels must be unique within a question)", }; return alloc.dupe(u8, body); } fn freeAnswers(alloc: Allocator, answers: [][]u8) void { for (answers) |answer| alloc.free(answer); alloc.free(answers); } pub fn readsOnly(_: tool_dispatch.ToolInput) bool { return true; } pub fn isIrreversible(_: tool_dispatch.ToolInput) bool { return false; } fn requesterCancel(_: ?*anyopaque, _: Allocator, _: []const core_types.QuestionBatchEntry) anyerror!?[][]u8 { return null; } const FakeRequester = struct { called: bool = false, saw_trimmed: bool = false, fn request(raw_ctx: ?*anyopaque, alloc: Allocator, entries: []const core_types.QuestionBatchEntry) anyerror!?[][]u8 { const self: *FakeRequester = @ptrCast(@alignCast(raw_ctx.?)); self.called = true; if (entries.len == 1 and std.mem.eql(u8, entries[0].question, "Choose?") and entries[0].options.len == 2 and std.mem.eql(u8, entries[0].options[0].label, "Yes") and entries[0].options[0].description != null and std.mem.eql(u8, entries[0].options[0].description.?, "Go ahead") and entries[0].options[1].description == null) { self.saw_trimmed = true; } const answers = try alloc.alloc([]u8, entries.len); errdefer alloc.free(answers); var filled: usize = 0; errdefer { var i: usize = 0; while (i < filled) : (i += 1) alloc.free(answers[i]); } while (filled < entries.len) : (filled += 1) { answers[filled] = try alloc.dupe(u8, entries[filled].options[0].label); } return answers; } }; const TerminalSafeRequester = struct { called: bool = false, saw_terminal_safe: bool = false, fn request(raw_ctx: ?*anyopaque, alloc: Allocator, entries: []const core_types.QuestionBatchEntry) anyerror!?[][]u8 { const self: *TerminalSafeRequester = @ptrCast(@alignCast(raw_ctx.?)); self.called = true; if (entries.len == 1 and std.mem.eql(u8, entries[0].question, "Q\\x0a\\x1b[31m?") and entries[0].options.len == 2 and std.mem.eql(u8, entries[0].options[0].label, "Alpha\\x0aFake") and entries[0].options[0].description != null and std.mem.eql(u8, entries[0].options[0].description.?, "Desc\\x09Gap") and std.mem.eql(u8, entries[0].options[1].label, "\\x1b[31mRed\\x1b[0m")) { self.saw_terminal_safe = true; } const answers = try alloc.alloc([]u8, entries.len); errdefer alloc.free(answers); var filled: usize = 0; errdefer { var i: usize = 0; while (i < filled) : (i += 1) alloc.free(answers[i]); } while (filled < entries.len) : (filled += 1) { answers[filled] = try alloc.dupe(u8, entries[filled].options[0].label); } return answers; } }; fn expectRequesterOutput(args_json: []const u8, expected: []const u8) !void { const alloc = std.testing.allocator; var fake = FakeRequester{}; const output = try executeWithRequester(alloc, args_json, .{ .ctx = &fake, .response_alloc = alloc, .request = FakeRequester.request, }); defer alloc.free(output); try std.testing.expectEqualStrings(expected, output); } test "ask_user_question validates arguments and returns active sentinels" { try expectRequesterOutput("not-json", invalid_args_sentinel); try expectRequesterOutput("[]", invalid_args_sentinel); try expectRequesterOutput("{}", "(ask_user_question: missing required array \"questions\")"); try expectRequesterOutput("{\"questions\":{}}", "(ask_user_question: \"questions\" must be an array)"); try expectRequesterOutput("{\"questions\":[]}", "(ask_user_question: provide 1 to 4 questions)"); try expectRequesterOutput("{\"questions\":[1]}", "(ask_user_question: each question must be an object with a \"question\" and \"options\")"); try expectRequesterOutput("{\"questions\":[{}]}", "(ask_user_question: each question requires a \"question\" string)"); try expectRequesterOutput("{\"questions\":[{\"question\":1}]}", "(ask_user_question: question \"question\" must be a string)"); try expectRequesterOutput("{\"questions\":[{\"question\":\" \",\"options\":[]}]}", "(ask_user_question: question text must not be empty)"); try expectRequesterOutput("{\"questions\":[{\"question\":\"Q?\"}]}", "(ask_user_question: each question requires an \"options\" array)"); try expectRequesterOutput("{\"questions\":[{\"question\":\"Q?\",\"options\":1}]}", "(ask_user_question: \"options\" must be an array)"); try expectRequesterOutput("{\"questions\":[{\"question\":\"Q?\",\"options\":[]}]}", "(ask_user_question: provide 2 to 6 options per question)"); try expectRequesterOutput("{\"questions\":[{\"question\":\"Q?\",\"options\":[1,2]}]}", "(ask_user_question: each option must be an object with a \"label\")"); try expectRequesterOutput("{\"questions\":[{\"question\":\"Q?\",\"options\":[{},{}]}]}", "(ask_user_question: each option requires a \"label\" string)"); try expectRequesterOutput("{\"questions\":[{\"question\":\"Q?\",\"options\":[{\"label\":1},{\"label\":\"No\"}]}]}", "(ask_user_question: option \"label\" must be a string)"); try expectRequesterOutput("{\"questions\":[{\"question\":\"Q?\",\"options\":[{\"label\":\"\"},{\"label\":\"No\"}]}]}", "(ask_user_question: option labels must not be empty)"); try expectRequesterOutput("{\"questions\":[{\"question\":\"Q?\",\"options\":[{\"label\":\"Yes\"},{\"label\":\" yes \"}]}]}", "(ask_user_question: option labels must be unique within a question)"); } test "ask_user_question normalizes descriptions and encodes ordered answers" { const alloc = std.testing.allocator; var fake = FakeRequester{}; const output = try executeWithRequester( alloc, "{\"questions\":[{\"question\":\" Choose? \",\"options\":[{\"label\":\" Yes \",\"description\":\" Go ahead \"},{\"label\":\"No\",\"description\":3}]}]}", .{ .ctx = &fake, .response_alloc = alloc, .request = FakeRequester.request, }, ); defer alloc.free(output); try std.testing.expect(fake.called); try std.testing.expect(fake.saw_trimmed); try std.testing.expectEqualStrings("[{\"question\":\"Choose?\",\"answer\":\"Yes\"}]", output); } test "ask_user_question encodes model-controlled prompt text for terminal display" { const alloc = std.testing.allocator; var requester = TerminalSafeRequester{}; const output = try executeWithRequester( alloc, "{\"questions\":[{\"question\":\"Q\\n\\u001b[31m?\",\"options\":[{\"label\":\"Alpha\\nFake\",\"description\":\"Desc\\tGap\"},{\"label\":\"\\u001b[31mRed\\u001b[0m\"}]}]}", .{ .ctx = &requester, .response_alloc = alloc, .request = TerminalSafeRequester.request, }, ); defer alloc.free(output); try std.testing.expect(requester.called); try std.testing.expect(requester.saw_terminal_safe); try std.testing.expectEqualStrings( "[{\"question\":\"Q\\\\x0a\\\\x1b[31m?\",\"answer\":\"Alpha\\\\x0aFake\"}]", output, ); } test "ask_user_question cancellation returns sentinel" { const alloc = std.testing.allocator; const output = try executeWithRequester( alloc, "{\"questions\":[{\"question\":\"Q?\",\"options\":[{\"label\":\"Yes\"},{\"label\":\"No\"}]}]}", .{ .ctx = null, .response_alloc = alloc, .request = requesterCancel, }, ); defer alloc.free(output); try std.testing.expectEqualStrings(cancel_sentinel, output); } test "ask_user_question noninteractive returns sentinel before parsing" { const alloc = std.testing.allocator; const decoded = try decode(.{ .allocator = alloc }, "not-json"); const input = switch (decoded) { .failure => |body| { defer alloc.free(body); return error.TestExpectedEqual; }, .input => |owned| owned, }; defer input.deinit(alloc); const result = try call(.{ .allocator = alloc }, input); defer result.deinit(alloc); switch (result) { .success => |body| try std.testing.expectEqualStrings(not_available_sentinel, body), .failure => return error.TestExpectedEqual, } }