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
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
|
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.beginArray();
// Read tool
try jw.write(.{
.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"},
},
},
});
// Write tool
try jw.write(.{
.type = "function",
.function = .{
.name = "Write",
.description = "Write content to a file",
.parameters = .{
.type = "object",
.properties = .{
.file_path = .{
.type = "string",
.description = "The path of the file to write to",
},
.content = .{
.type = "string",
.description = "The content to write to the file",
},
},
.required = &[_][]const u8{ "file_path", "content" },
},
},
});
// Bash tool
try jw.write(.{
.type = "function",
.function = .{
.name = "Bash",
.description = "Execute a shell command",
.parameters = .{
.type = "object",
.properties = .{
.command = .{
.type = "string",
.description = "The command to execute",
},
},
.required = &[_][]const u8{"command"},
},
},
});
try jw.endArray();
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));
} else if (std.mem.eql(u8, func_name, "Write")) {
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;
const content = args_parsed.value.object.get("content").?.string;
std.debug.print("Writing file: {s}\n", .{file_path});
// Create parent directories if needed
if (std.fs.path.dirname(file_path)) |dir| {
try std.fs.cwd().makePath(dir);
}
try std.fs.cwd().writeFile(.{ .sub_path = file_path, .data = content });
result_owned = try allocator.dupe(u8, "OK");
} else if (std.mem.eql(u8, func_name, "Bash")) {
const args_parsed = try std.json.parseFromSlice(std.json.Value, allocator, arguments_str, .{});
defer args_parsed.deinit();
const command = args_parsed.value.object.get("command").?.string;
std.debug.print("Running command: {s}\n", .{command});
const result = try std.process.Child.run(.{
.allocator = allocator,
.argv = &.{ "sh", "-c", command },
});
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
// Combine stdout + stderr into the tool result
result_owned = try std.fmt.allocPrint(allocator, "{s}{s}", .{ result.stdout, result.stderr });
}
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;
}
}
}
|