const std = @import("std"); const app_worker_runtime = @import("app_worker_runtime.zig"); const image_attachments = @import("../images/image_attachments.zig"); const tool_result_errors = @import("../tooling/tool_result_errors.zig"); const task_helpers = @import("../tasks/task_helpers.zig"); const types = @import("../shared/types.zig"); const worker_runtime = @import("../agent/worker_runtime.zig"); pub fn Runtime(comptime App: type) type { return struct { pub fn startWorkerThread(app: *App) !void { app.worker_thread = try std.Thread.spawn(.{}, workerThreadMain, .{app}); } /// Starts one queued prompt on a single-threaded host. Prompt admission /// is presented before agent work can suspend on host transport. pub fn processNextCooperativePrompt( app: *App, on_task_completion: *const fn (*anyopaque, task_helpers.TaskCompletion) void, event_handlers: app_worker_runtime.WorkerEventHandlers, flush_frame: *const fn (*App) anyerror!void, ) !void { const job = (try app.worker.tryTakeNextPrompt(std.heap.c_allocator)) orelse return; defer worker_runtime.freeQueuedPrompt(std.heap.c_allocator, job); try app_worker_runtime.Runtime(App).tick( app, on_task_completion, event_handlers, ); try flush_frame(app); app.processQueuedPrompt(job) catch |err| { if (err != error.RouteRecoveryStopped) { const body = try formatErrorBody(std.heap.c_allocator, "request failed", err); defer std.heap.c_allocator.free(body); try app.worker.pushEvent(std.heap.c_allocator, .{ .error_text = .{ .topic = "system", .tone = .@"error", .body = body, } }); } }; app.worker.finishProcessing(); } pub fn formatToolExecutionError(alloc: std.mem.Allocator, tool_name: []const u8, err: anyerror) ![]u8 { const error_detail = detailedErrorSummary(err); const details: []const tool_result_errors.Detail = if (error_detail) |detail| &[_]tool_result_errors.Detail{ .{ .name = "error", .value = .{ .string = @errorName(err) } }, .{ .name = "detail", .value = .{ .string = detail } }, } else &[_]tool_result_errors.Detail{ .{ .name = "error", .value = .{ .string = @errorName(err) } }, }; return tool_result_errors.toolExecutionFailureJson(alloc, .{ .tool_name = tool_name, .message = if (error_detail != null) "Tool execution failed while parsing or preparing data" else "Tool execution failed", .details = details, .suggestion = suggestionForError(err), }); } fn detailedErrorSummary(err: anyerror) ?[]const u8 { return switch (err) { error.StreamInterrupted => "provider response ended before completion", error.UnexpectedEndOfInput => "incomplete JSON or internal response while parsing data", error.SyntaxError => "invalid JSON syntax while parsing data", error.MissingField => "required field missing while parsing data", error.InvalidEnumTag => "invalid enum value while parsing data", error.UnknownField => "unexpected field while parsing data", error.ImageTooLarge => image_attachments.image_too_large_notice, error.McpInputTimedOut => tool_result_errors.executionErrorMessage(err), else => null, }; } fn suggestionForError(err: anyerror) ?[]const u8 { return switch (err) { error.UnexpectedEndOfInput, error.SyntaxError => "Check that the tool arguments are valid JSON and retry with corrected input.", error.MissingField => "Add the required input field named by the tool schema, then retry.", error.InvalidEnumTag, error.UnknownField => "Compare the input fields with the tool schema and retry with supported values.", error.ImageTooLarge => "Use a smaller image or reduce the image before retrying.", error.McpInputTimedOut => tool_result_errors.executionErrorSuggestion(err), else => null, }; } fn formatErrorBody(alloc: std.mem.Allocator, context: []const u8, err: anyerror) ![]u8 { switch (err) { error.ConnectionSetupTimedOut => return alloc.dupe(u8, "Connection setup timed out after 30 seconds."), error.TlsInitializationFailed => return alloc.dupe(u8, "Connection setup failed: TLS could not be initialized."), else => {}, } if (detailedErrorSummary(err)) |detail| { return std.fmt.allocPrint(alloc, "{s}: {s} ({s})", .{ context, detail, @errorName(err) }); } return std.fmt.allocPrint(alloc, "{s}: {s}", .{ context, @errorName(err) }); } fn workerThreadMain(app: *App) void { workerLoop(app) catch |err| { const body = formatErrorBody(std.heap.c_allocator, "worker loop crashed", err) catch return; defer std.heap.c_allocator.free(body); app.worker.pushEvent(std.heap.c_allocator, .{ .error_text = .{ .topic = "system", .tone = .@"error", .body = body, } }) catch return; }; } fn workerLoop(app: *App) !void { while (true) { const job = (try app.worker.waitAndTakeNextPrompt(std.heap.c_allocator)) orelse return; defer worker_runtime.freeQueuedPrompt(std.heap.c_allocator, job); app.processQueuedPrompt(job) catch |err| { if (err != error.RouteRecoveryStopped) { const body = try formatErrorBody(std.heap.c_allocator, "request failed", err); defer std.heap.c_allocator.free(body); try app.worker.pushEvent(std.heap.c_allocator, .{ .error_text = .{ .topic = "system", .tone = .@"error", .body = body, } }); } }; app.worker.finishProcessing(); } } }; } const DummyApp = struct { worker: ?void = null, worker_thread: ?std.Thread = null, }; test "formatToolExecutionError includes tool name" { const alloc = std.testing.allocator; const Rt = Runtime(DummyApp); const msg = try Rt.formatToolExecutionError(alloc, "write_file", error.FileNotFound); defer alloc.free(msg); try std.testing.expect(std.mem.find(u8, msg, "write_file") != null); try std.testing.expect(std.mem.find(u8, msg, "FileNotFound") != null); try std.testing.expect(tool_result_errors.isToolExecutionFailedOutput(msg)); } test "formatToolExecutionError includes detail for SyntaxError" { const alloc = std.testing.allocator; const Rt = Runtime(DummyApp); const msg = try Rt.formatToolExecutionError(alloc, "edit_file", error.SyntaxError); defer alloc.free(msg); try std.testing.expect(std.mem.find(u8, msg, "invalid JSON syntax") != null); try std.testing.expect(std.mem.find(u8, msg, "edit_file") != null); } test "formatErrorBody describes an interrupted provider response without presentation bytes" { const alloc = std.testing.allocator; const Rt = Runtime(DummyApp); const body = try Rt.formatErrorBody(alloc, "request failed", error.StreamInterrupted); defer alloc.free(body); try std.testing.expect(std.mem.find(u8, body, "provider response ended before completion") != null); try std.testing.expect(std.mem.find(u8, body, "StreamInterrupted") != null); try std.testing.expect(std.mem.find(u8, body, "\x1b[") == null); try std.testing.expect(!std.mem.endsWith(u8, body, "\n")); } test "formatErrorBody describes terminal connection setup failures plainly" { const alloc = std.testing.allocator; const Rt = Runtime(DummyApp); const timeout = try Rt.formatErrorBody(alloc, "request failed", error.ConnectionSetupTimedOut); defer alloc.free(timeout); try std.testing.expectEqualStrings("Connection setup timed out after 30 seconds.", timeout); const tls = try Rt.formatErrorBody(alloc, "request failed", error.TlsInitializationFailed); defer alloc.free(tls); try std.testing.expectEqualStrings("Connection setup failed: TLS could not be initialized.", tls); for ([_][]const u8{ timeout, tls }) |body| { try std.testing.expect(std.mem.find(u8, body, "\x1b[") == null); try std.testing.expect(!std.mem.endsWith(u8, body, "\n")); try std.testing.expect(std.mem.find(u8, body, "ConnectionSetupTimedOut") == null); try std.testing.expect(std.mem.find(u8, body, "TlsInitializationFailed") == null); } const unrelated_timeout = try Rt.formatErrorBody(alloc, "request failed", error.ConnectionTimedOut); defer alloc.free(unrelated_timeout); try std.testing.expectEqualStrings("request failed: ConnectionTimedOut", unrelated_timeout); } test "formatToolExecutionError includes detail for MissingField" { const alloc = std.testing.allocator; const Rt = Runtime(DummyApp); const msg = try Rt.formatToolExecutionError(alloc, "read_file", error.MissingField); defer alloc.free(msg); try std.testing.expect(std.mem.find(u8, msg, "required field missing") != null); } test "formatToolExecutionError includes detail for ImageTooLarge" { const alloc = std.testing.allocator; const Rt = Runtime(DummyApp); const msg = try Rt.formatToolExecutionError(alloc, "image_tool", error.ImageTooLarge); defer alloc.free(msg); try std.testing.expect(std.mem.find(u8, msg, "image exceeds") != null); } test "formatToolExecutionError generic error has no detail" { const alloc = std.testing.allocator; const Rt = Runtime(DummyApp); const msg = try Rt.formatToolExecutionError(alloc, "run_command", error.OutOfMemory); defer alloc.free(msg); try std.testing.expect(tool_result_errors.isToolExecutionFailedOutput(msg)); try std.testing.expect(std.mem.find(u8, msg, "run_command") != null); try std.testing.expect(std.mem.find(u8, msg, "OutOfMemory") != null); } const TestWorkerApp = struct { worker: worker_runtime.WorkerRuntime = .{}, worker_thread: ?std.Thread = null, first_process_error: ?anyerror = null, shutdown_after_count: usize = 1, processed_count: usize = 0, successful_count: usize = 0, saw_recovery_prompt: bool = false, fn deinit(self: *TestWorkerApp) void { if (self.worker_thread) |thread| { self.worker.requestShutdown(); thread.join(); self.worker_thread = null; } self.worker.deinit(std.heap.c_allocator); } fn processQueuedPrompt(self: *TestWorkerApp, job: worker_runtime.QueuedPrompt) !void { self.processed_count += 1; if (self.processed_count >= self.shutdown_after_count) self.worker.requestShutdown(); if (self.processed_count == 1) { if (self.first_process_error) |err| return err; } self.successful_count += 1; self.saw_recovery_prompt = std.mem.eql(u8, job.prompt, "recovery"); } }; fn makeQueuedPrompt(alloc: std.mem.Allocator, text: []const u8) !worker_runtime.QueuedPrompt { return .{ .prompt = try alloc.dupe(u8, text), .images = &.{}, .model = try alloc.dupe(u8, "test-model"), .api_key = try alloc.dupe(u8, "test-key"), .permission_mode = .ask, .history = try alloc.alloc(types.HistoryTurn, 0), .grants = try alloc.alloc(types.PermissionGrant, 0), }; } fn queuePrompt(app: *TestWorkerApp, text: []const u8) !void { const prompt = try makeQueuedPrompt(std.heap.c_allocator, text); errdefer worker_runtime.freeQueuedPrompt(std.heap.c_allocator, prompt); try app.worker.enqueuePrompt(std.heap.c_allocator, prompt); } test "startWorkerThread processes queued prompt and exits on shutdown" { var app = TestWorkerApp{}; defer app.deinit(); try Runtime(TestWorkerApp).startWorkerThread(&app); try queuePrompt(&app, "hello"); const thread = app.worker_thread.?; thread.join(); app.worker_thread = null; try std.testing.expectEqual(@as(usize, 1), app.processed_count); const snapshot = try app.worker.snapshotState(std.testing.allocator); defer snapshot.deinit(std.testing.allocator); try std.testing.expect(!snapshot.processing); try std.testing.expectEqual(@as(usize, 0), snapshot.queued_count); } test "worker thread reports queued prompt failures as worker events" { var app = TestWorkerApp{ .first_process_error = error.SyntaxError }; defer app.deinit(); try Runtime(TestWorkerApp).startWorkerThread(&app); try queuePrompt(&app, "bad json"); const thread = app.worker_thread.?; thread.join(); app.worker_thread = null; var events = app.worker.takeEvents(); defer events.deinit(std.heap.c_allocator); defer for (events.items) |event| worker_runtime.freeWorkerEvent(std.heap.c_allocator, event); var saw_error = false; for (events.items) |event| { switch (event) { .error_text => |notice| { saw_error = true; try std.testing.expectEqualStrings("system", notice.topic); try std.testing.expectEqual(types.NoticeTone.@"error", notice.tone); try std.testing.expect(std.mem.find(u8, notice.body, "request failed") != null); try std.testing.expect(std.mem.find(u8, notice.body, "invalid JSON syntax") != null); try std.testing.expect(std.mem.find(u8, notice.body, "SyntaxError") != null); }, else => {}, } } try std.testing.expect(saw_error); } test "worker continues after project context selector failures" { const errors = [_]anyerror{ error.OutOfMemory, error.NoSpaceLeft, error.WriteFailed, }; for (errors) |expected_error| { var app = TestWorkerApp{ .first_process_error = expected_error, .shutdown_after_count = 2, }; defer app.deinit(); try queuePrompt(&app, "selector failure"); try queuePrompt(&app, "recovery"); try Runtime(TestWorkerApp).startWorkerThread(&app); const thread = app.worker_thread.?; thread.join(); app.worker_thread = null; try std.testing.expectEqual(@as(usize, 2), app.processed_count); try std.testing.expectEqual(@as(usize, 1), app.successful_count); try std.testing.expect(app.saw_recovery_prompt); const snapshot = try app.worker.snapshotState(std.testing.allocator); defer snapshot.deinit(std.testing.allocator); try std.testing.expect(!snapshot.processing); try std.testing.expectEqual(@as(usize, 0), snapshot.queued_count); var events = app.worker.takeEvents(); defer events.deinit(std.heap.c_allocator); defer for (events.items) |event| worker_runtime.freeWorkerEvent(std.heap.c_allocator, event); var error_count: usize = 0; for (events.items) |event| switch (event) { .error_text => |notice| { error_count += 1; try std.testing.expect(std.mem.find(u8, notice.body, "request failed") != null); try std.testing.expect(std.mem.find(u8, notice.body, @errorName(expected_error)) != null); }, else => {}, }; try std.testing.expectEqual(@as(usize, 1), error_count); } }