|
| 1 | +// main.zig — TRI-API: Direct Anthropic API agentic loop |
| 2 | +// No claude CLI dependency. Talks to api.anthropic.com/v1/messages directly. |
| 3 | +// Self-contained in src/tri-api/. Issue #60. |
| 4 | +const std = @import("std"); |
| 5 | +const proto = @import("tool_protocol.zig"); |
| 6 | +const executor = @import("tool_executor.zig"); |
| 7 | + |
| 8 | +const api_url = "https://api.anthropic.com/v1/messages"; |
| 9 | +const api_version = "2023-06-01"; |
| 10 | +const max_turns = 20; |
| 11 | +const default_model = "claude-sonnet-4-20250514"; |
| 12 | + |
| 13 | +pub fn main() !void { |
| 14 | + var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; |
| 15 | + defer _ = gpa.deinit(); |
| 16 | + const allocator = gpa.allocator(); |
| 17 | + |
| 18 | + // Read API key |
| 19 | + const api_key = std.process.getEnvVarOwned(allocator, "ANTHROPIC_API_KEY") catch { |
| 20 | + std.debug.print("error: ANTHROPIC_API_KEY not set\n", .{}); |
| 21 | + std.process.exit(1); |
| 22 | + }; |
| 23 | + defer allocator.free(api_key); |
| 24 | + |
| 25 | + // Parse CLI args: [--model <model>] <prompt...> |
| 26 | + const args = try std.process.argsAlloc(allocator); |
| 27 | + defer std.process.argsFree(allocator, args); |
| 28 | + |
| 29 | + var model: []const u8 = default_model; |
| 30 | + var prompt_start: usize = 1; // skip argv[0] |
| 31 | + |
| 32 | + if (args.len > 2 and std.mem.eql(u8, args[1], "--model")) { |
| 33 | + model = args[2]; |
| 34 | + prompt_start = 3; |
| 35 | + } |
| 36 | + |
| 37 | + if (prompt_start >= args.len) { |
| 38 | + std.debug.print("usage: tri-api [--model <model>] <prompt>\n", .{}); |
| 39 | + std.process.exit(1); |
| 40 | + } |
| 41 | + |
| 42 | + // Join remaining args as prompt |
| 43 | + const prompt = std.mem.join(allocator, " ", args[prompt_start..]) catch { |
| 44 | + std.debug.print("error: out of memory\n", .{}); |
| 45 | + std.process.exit(1); |
| 46 | + }; |
| 47 | + defer allocator.free(prompt); |
| 48 | + |
| 49 | + std.debug.print("[tri-api] model={s} prompt={d} chars\n", .{ model, prompt.len }); |
| 50 | + |
| 51 | + // Build conversation: messages accumulate across turns |
| 52 | + var messages = std.ArrayList(u8).empty; |
| 53 | + defer messages.deinit(allocator); |
| 54 | + |
| 55 | + // Initial user message |
| 56 | + try messages.appendSlice(allocator, "[{\"role\":\"user\",\"content\":\""); |
| 57 | + try proto.writeJsonEscaped(messages.writer(allocator), prompt); |
| 58 | + try messages.appendSlice(allocator, "\"}"); |
| 59 | + |
| 60 | + var tool_exec = executor.ToolExecutor{ .allocator = allocator }; |
| 61 | + var total_input_tokens: u32 = 0; |
| 62 | + var total_output_tokens: u32 = 0; |
| 63 | + |
| 64 | + // Agentic loop |
| 65 | + var turn: u32 = 0; |
| 66 | + while (turn < max_turns) : (turn += 1) { |
| 67 | + // Close messages array |
| 68 | + var request_body = std.ArrayList(u8).empty; |
| 69 | + defer request_body.deinit(allocator); |
| 70 | + |
| 71 | + try request_body.appendSlice(allocator, "{\"model\":\""); |
| 72 | + try request_body.appendSlice(allocator, model); |
| 73 | + try request_body.appendSlice(allocator, "\",\"max_tokens\":8192,\"tools\":"); |
| 74 | + try proto.writeToolDefinitions(request_body.writer(allocator)); |
| 75 | + try request_body.appendSlice(allocator, ",\"messages\":"); |
| 76 | + try request_body.appendSlice(allocator, messages.items); |
| 77 | + try request_body.appendSlice(allocator, "]}"); |
| 78 | + |
| 79 | + std.debug.print("[tri-api] turn {d}: sending {d} bytes...\n", .{ turn + 1, request_body.items.len }); |
| 80 | + |
| 81 | + // POST to Anthropic API |
| 82 | + const response_body = httpPost(allocator, api_key, request_body.items) catch |err| { |
| 83 | + std.debug.print("[tri-api] HTTP error: {s}\n", .{@errorName(err)}); |
| 84 | + break; |
| 85 | + }; |
| 86 | + defer allocator.free(response_body); |
| 87 | + |
| 88 | + // Parse response |
| 89 | + var parsed = proto.parseResponse(allocator, response_body); |
| 90 | + defer parsed.deinit(allocator); |
| 91 | + |
| 92 | + total_input_tokens += parsed.input_tokens; |
| 93 | + total_output_tokens += parsed.output_tokens; |
| 94 | + |
| 95 | + // Process content blocks |
| 96 | + var has_tool_use = false; |
| 97 | + |
| 98 | + // Build assistant message for conversation history |
| 99 | + try messages.appendSlice(allocator, ",{\"role\":\"assistant\",\"content\":"); |
| 100 | + try messages.appendSlice(allocator, extractContentArray(response_body) orelse "[]"); |
| 101 | + try messages.appendSlice(allocator, "}"); |
| 102 | + |
| 103 | + for (parsed.blocks.items) |block| { |
| 104 | + switch (block) { |
| 105 | + .text => |text| { |
| 106 | + const stdout_file = std.fs.File.stdout(); |
| 107 | + var write_buf: [4096]u8 = undefined; |
| 108 | + var w = stdout_file.writer(&write_buf); |
| 109 | + std.Io.Writer.writeAll(&w.interface, text) catch {}; |
| 110 | + std.Io.Writer.writeAll(&w.interface, "\n") catch {}; |
| 111 | + w.end() catch {}; |
| 112 | + }, |
| 113 | + .tool_use => |tool| { |
| 114 | + has_tool_use = true; |
| 115 | + std.debug.print("[tri-api] tool: {s}({s})\n", .{ tool.name, tool.id }); |
| 116 | + |
| 117 | + const tool_name = executor.ToolName.fromString(tool.name) orelse { |
| 118 | + std.debug.print("[tri-api] unknown tool: {s}\n", .{tool.name}); |
| 119 | + continue; |
| 120 | + }; |
| 121 | + |
| 122 | + const result = tool_exec.execute(tool_name, tool.input_json); |
| 123 | + |
| 124 | + // Append tool result to messages |
| 125 | + try messages.appendSlice(allocator, ",{\"role\":\"user\",\"content\":["); |
| 126 | + try proto.writeToolResult(messages.writer(allocator), tool.id, result.output, result.is_error); |
| 127 | + try messages.appendSlice(allocator, "]}"); |
| 128 | + }, |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + // Check stop condition |
| 133 | + if (std.mem.eql(u8, parsed.stop_reason, "end_turn") or !has_tool_use) { |
| 134 | + std.debug.print("[tri-api] done: {s}\n", .{parsed.stop_reason}); |
| 135 | + break; |
| 136 | + } |
| 137 | + } |
| 138 | + |
| 139 | + std.debug.print("[tri-api] {d} turns, {d} input + {d} output tokens\n", .{ turn + 1, total_input_tokens, total_output_tokens }); |
| 140 | +} |
| 141 | + |
| 142 | +/// Extract the raw "content":[...] array from response body. |
| 143 | +fn extractContentArray(body: []const u8) ?[]const u8 { |
| 144 | + const needle = "\"content\":["; |
| 145 | + const idx = std.mem.indexOf(u8, body, needle) orelse return null; |
| 146 | + const start = idx + "\"content\":".len; |
| 147 | + // Find matching ] |
| 148 | + var depth: u32 = 0; |
| 149 | + var end = start; |
| 150 | + var in_string = false; |
| 151 | + while (end < body.len) : (end += 1) { |
| 152 | + if (in_string) { |
| 153 | + if (body[end] == '"' and (end == 0 or body[end - 1] != '\\')) in_string = false; |
| 154 | + continue; |
| 155 | + } |
| 156 | + switch (body[end]) { |
| 157 | + '"' => in_string = true, |
| 158 | + '[' => depth += 1, |
| 159 | + ']' => { |
| 160 | + depth -= 1; |
| 161 | + if (depth == 0) return body[start .. end + 1]; |
| 162 | + }, |
| 163 | + else => {}, |
| 164 | + } |
| 165 | + } |
| 166 | + return null; |
| 167 | +} |
| 168 | + |
| 169 | +/// POST JSON to Anthropic Messages API using Zig 0.15 std.http.Client. |
| 170 | +fn httpPost(allocator: std.mem.Allocator, api_key: []const u8, body: []const u8) ![]const u8 { |
| 171 | + var client = std.http.Client{ .allocator = allocator }; |
| 172 | + defer client.deinit(); |
| 173 | + |
| 174 | + const uri = std.Uri.parse(api_url) catch unreachable; |
| 175 | + |
| 176 | + var req = client.request(.POST, uri, .{ |
| 177 | + .extra_headers = &.{ |
| 178 | + .{ .name = "Content-Type", .value = "application/json" }, |
| 179 | + .{ .name = "x-api-key", .value = api_key }, |
| 180 | + .{ .name = "anthropic-version", .value = api_version }, |
| 181 | + }, |
| 182 | + }) catch return error.ConnectionFailed; |
| 183 | + defer req.deinit(); |
| 184 | + |
| 185 | + req.transfer_encoding = .{ .content_length = body.len }; |
| 186 | + var body_writer = req.sendBodyUnflushed(&.{}) catch return error.ConnectionFailed; |
| 187 | + body_writer.writer.writeAll(body) catch return error.ConnectionFailed; |
| 188 | + body_writer.end() catch return error.ConnectionFailed; |
| 189 | + if (req.connection) |conn| conn.flush() catch {}; |
| 190 | + |
| 191 | + var redirect_buf: [0]u8 = .{}; |
| 192 | + var response = req.receiveHead(&redirect_buf) catch return error.ConnectionFailed; |
| 193 | + |
| 194 | + if (@intFromEnum(response.head.status) >= 400) { |
| 195 | + std.debug.print("[tri-api] API status: {d}\n", .{@intFromEnum(response.head.status)}); |
| 196 | + } |
| 197 | + |
| 198 | + var transfer_buf: [8192]u8 = undefined; |
| 199 | + var reader = response.reader(&transfer_buf); |
| 200 | + const resp_body = reader.allocRemaining(allocator, std.Io.Limit.limited(10 * 1024 * 1024)) catch return error.OutOfMemory; |
| 201 | + |
| 202 | + return resp_body; |
| 203 | +} |
0 commit comments