1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
|
const std = @import("std");
// A single message in the conversation history. We own all string memory.
const Message = struct {
role: []const u8,
content: ?[]const u8, // null for assistant messages that only have tool_calls
// For tool result messages
tool_call_id: ?[]const u8 = null,
// For assistant messages that contain tool calls (raw JSON value kept so
// we can re-serialize it verbatim)
tool_calls_raw: ?std.json.Value = null,
allocator: std.mem.Allocator,
fn deinit(self: *Message) void {
self.allocator.free(self.role);
if (self.content) |c| self.allocator.free(c);
if (self.tool_call_id) |id| self.allocator.free(id);
}
};
// Serialize the conversation history into the JSON messages array.
fn writeMessages(jw: *std.json.Stringify, messages: []const Message) !void {
try jw.beginArray();
for (messages) |msg| {
try jw.beginObject();
try jw.objectField("role");
try jw.write(msg.role);
if (msg.tool_call_id) |id| {
// tool result message
try jw.objectField("tool_call_id");
try jw.write(id);
}
if (msg.tool_calls_raw) |tc| {
// assistant message with tool_calls: write content as null
try jw.objectField("content");
try jw.write(null);
try jw.objectField("tool_calls");
try jw.write(tc);
} else {
try jw.objectField("content");
if (msg.content) |c| {
try jw.write(c);
} else {
try jw.write(null);
}
}
try jw.endObject();
}
try jw.endArray();
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Parse -p flag
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
var prompt: ?[]const u8 = null;
var i: usize = 1;
while (i < args.len) : (i += 1) {
if (std.mem.eql(u8, args[i], "-p") and i + 1 < args.len) {
i += 1;
prompt = args[i];
}
}
const prompt_str = prompt orelse @panic("Prompt must not be empty");
const api_key = std.posix.getenv("OPENROUTER_API_KEY") orelse @panic("OPENROUTER_API_KEY is not set");
const base_url = std.posix.getenv("OPENROUTER_BASE_URL") orelse "https://openrouter.ai/api/v1";
const url_str = try std.fmt.allocPrint(allocator, "{s}/chat/completions", .{base_url});
defer allocator.free(url_str);
const auth_value = try std.fmt.allocPrint(allocator, "Bearer {s}", .{api_key});
defer allocator.free(auth_value);
// Conversation history — seed with the user prompt.
// In Zig 0.15 ArrayList is unmanaged: init via .{}, pass allocator to each op.
var messages: std.ArrayList(Message) = .{};
defer {
for (messages.items) |*m| m.deinit();
messages.deinit(allocator);
}
try messages.append(allocator, .{
.role = try allocator.dupe(u8, "user"),
.content = try allocator.dupe(u8, prompt_str),
.allocator = allocator,
});
var client: std.http.Client = .{ .allocator = allocator };
defer client.deinit();
// Keep parsed responses alive so tool_calls_raw values remain valid.
var parsed_responses: std.ArrayList(std.json.Parsed(std.json.Value)) = .{};
defer {
for (parsed_responses.items) |*p| p.deinit();
parsed_responses.deinit(allocator);
}
// ── Agent loop ────────────────────────────────────────────────────────────
while (true) {
// Build request body
var body_out: std.io.Writer.Allocating = .init(allocator);
defer body_out.deinit();
var jw: std.json.Stringify = .{ .writer = &body_out.writer };
try jw.beginObject();
try jw.objectField("model");
try jw.write("anthropic/claude-haiku-4.5");
try jw.objectField("messages");
try writeMessages(&jw, messages.items);
try jw.objectField("tools");
try jw.write(&[_]struct {
type: []const u8,
function: struct {
name: []const u8,
description: []const u8,
parameters: struct {
type: []const u8,
properties: struct {
file_path: struct {
type: []const u8,
description: []const u8,
},
},
required: []const []const u8,
},
},
}{.{
.type = "function",
.function = .{
.name = "Read",
.description = "Read and return the contents of a file",
.parameters = .{
.type = "object",
.properties = .{
.file_path = .{
.type = "string",
.description = "The path to the file to read",
},
},
.required = &[_][]const u8{"file_path"},
},
},
}});
try jw.endObject();
const body = body_out.written();
// Send request
var response_out: std.io.Writer.Allocating = .init(allocator);
defer response_out.deinit();
_ = try client.fetch(.{
.location = .{ .url = url_str },
.method = .POST,
.payload = body,
.extra_headers = &.{
.{ .name = "content-type", .value = "application/json" },
.{ .name = "authorization", .value = auth_value },
},
.response_writer = &response_out.writer,
});
const response_body = response_out.written();
std.debug.print("Response: {s}\n", .{response_body});
// Parse and keep alive for tool_calls_raw references
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, response_body, .{});
try parsed_responses.append(allocator, parsed);
const parsed_ptr = &parsed_responses.items[parsed_responses.items.len - 1];
const choices = parsed_ptr.value.object.get("choices") orelse @panic("No choices in response");
if (choices.array.items.len == 0) @panic("Empty choices array");
const choice = choices.array.items[0];
const finish_reason = choice.object.get("finish_reason").?.string;
const message = choice.object.get("message").?;
// Check whether this turn has tool calls
const has_tool_calls = blk: {
const tc = message.object.get("tool_calls") orelse break :blk false;
break :blk tc != .null and tc.array.items.len > 0;
};
if (has_tool_calls) {
const tc_val = message.object.get("tool_calls").?;
try messages.append(allocator, .{
.role = try allocator.dupe(u8, "assistant"),
.content = null,
.tool_calls_raw = tc_val,
.allocator = allocator,
});
// Execute every tool call and append results
for (tc_val.array.items) |tool_call| {
const call_id = tool_call.object.get("id").?.string;
const func = tool_call.object.get("function").?;
const func_name = func.object.get("name").?.string;
const arguments_str = func.object.get("arguments").?.string;
std.debug.print("Tool call: {s}\n", .{func_name});
var result_owned: []u8 = &.{};
if (std.mem.eql(u8, func_name, "Read")) {
const args_parsed = try std.json.parseFromSlice(std.json.Value, allocator, arguments_str, .{});
defer args_parsed.deinit();
const file_path = args_parsed.value.object.get("file_path").?.string;
std.debug.print("Reading file: {s}\n", .{file_path});
result_owned = try std.fs.cwd().readFileAlloc(allocator, file_path, std.math.maxInt(usize));
}
try messages.append(allocator, .{
.role = try allocator.dupe(u8, "tool"),
.content = result_owned,
.tool_call_id = try allocator.dupe(u8, call_id),
.allocator = allocator,
});
}
} else {
// No tool calls — record the assistant reply and stop
const content_val = message.object.get("content") orelse std.json.Value{ .null = {} };
const content_str = if (content_val == .string) content_val.string else "";
try messages.append(allocator, .{
.role = try allocator.dupe(u8, "assistant"),
.content = try allocator.dupe(u8, content_str),
.allocator = allocator,
});
}
// Stop when the model signals it's done
if (std.mem.eql(u8, finish_reason, "stop") or !has_tool_calls) {
const last = messages.items[messages.items.len - 1];
try std.fs.File.stdout().writeAll(last.content orelse "");
break;
}
}
}
|