Skip to content

Commit 9d7a558

Browse files
Antigravity Agentclaude
andcommitted
feat(tri-api): Direct Anthropic API agent — no claude CLI dependency (Issue #60)
Self-contained agentic loop in src/tri-api/ (624 LOC, 3 files): - tool_executor.zig: 4 tools (read_file, write_file, bash, grep) via std - tool_protocol.zig: Anthropic Messages API JSON build/parse - main.zig: POST to api.anthropic.com/v1/messages, tool_use loop Zero cross-directory imports. Talks to Claude API directly from Zig. Closes #60 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e4e768e commit 9d7a558

4 files changed

Lines changed: 642 additions & 0 deletions

File tree

build.zig

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2205,4 +2205,22 @@ pub fn build(b: *std.Build) void {
22052205
// Also run as part of build step
22062206
// b.getInstallStep().dependOn(&run_registry_export.step);
22072207

2208+
// ═══════════════════════════════════════════════════════════════════════════════
2209+
// TRI-API — Direct Anthropic API Agent (Issue #60)
2210+
// ═══════════════════════════════════════════════════════════════════════════════
2211+
2212+
const tri_api = b.addExecutable(.{
2213+
.name = "tri-api",
2214+
.root_module = b.createModule(.{
2215+
.root_source_file = b.path("src/tri-api/main.zig"),
2216+
.target = target,
2217+
.optimize = optimize,
2218+
}),
2219+
});
2220+
b.installArtifact(tri_api);
2221+
const run_tri_api = b.addRunArtifact(tri_api);
2222+
if (b.args) |args| run_tri_api.addArgs(args);
2223+
const tri_api_step = b.step("tri-api", "Run TRI-API — Direct Anthropic API Agent");
2224+
tri_api_step.dependOn(&run_tri_api.step);
2225+
22082226
}

src/tri-api/main.zig

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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+
}

src/tri-api/tool_executor.zig

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// tool_executor.zig — Execute tools (read_file, write_file, bash, grep) via std
2+
// Self-contained: no cross-directory imports. Uses std.fs + std.process.Child only.
3+
const std = @import("std");
4+
const json = @import("tool_protocol.zig");
5+
6+
pub const ToolName = enum {
7+
read_file,
8+
write_file,
9+
bash,
10+
grep,
11+
12+
pub fn fromString(name: []const u8) ?ToolName {
13+
if (std.mem.eql(u8, name, "read_file")) return .read_file;
14+
if (std.mem.eql(u8, name, "write_file")) return .write_file;
15+
if (std.mem.eql(u8, name, "bash")) return .bash;
16+
if (std.mem.eql(u8, name, "grep")) return .grep;
17+
return null;
18+
}
19+
};
20+
21+
pub const ToolResult = struct {
22+
output: []const u8,
23+
is_error: bool,
24+
};
25+
26+
pub const ToolExecutor = struct {
27+
allocator: std.mem.Allocator,
28+
29+
pub fn execute(self: *ToolExecutor, name: ToolName, input_json: []const u8) ToolResult {
30+
return switch (name) {
31+
.read_file => self.readFile(input_json),
32+
.write_file => self.writeFile(input_json),
33+
.bash => self.runBash(input_json),
34+
.grep => self.runGrep(input_json),
35+
};
36+
}
37+
38+
fn readFile(self: *ToolExecutor, input_json: []const u8) ToolResult {
39+
const path = json.extractField(input_json, "path") orelse
40+
return .{ .output = "error: missing 'path' field", .is_error = true };
41+
42+
const file = std.fs.cwd().openFile(path, .{}) catch |err|
43+
return self.errResult("read_file: open failed: ", err);
44+
45+
defer file.close();
46+
47+
const content = file.readToEndAlloc(self.allocator, 512 * 1024) catch |err|
48+
return self.errResult("read_file: read failed: ", err);
49+
50+
return .{ .output = content, .is_error = false };
51+
}
52+
53+
fn writeFile(self: *ToolExecutor, input_json: []const u8) ToolResult {
54+
const path = json.extractField(input_json, "path") orelse
55+
return .{ .output = "error: missing 'path' field", .is_error = true };
56+
const content = json.extractField(input_json, "content") orelse
57+
return .{ .output = "error: missing 'content' field", .is_error = true };
58+
59+
// Unescape JSON string content (handle \n, \t, \\, \")
60+
const unescaped = json.unescapeString(self.allocator, content) catch
61+
return .{ .output = "error: unescape failed", .is_error = true };
62+
defer self.allocator.free(unescaped);
63+
64+
const file = std.fs.cwd().createFile(path, .{}) catch |err|
65+
return self.errResult("write_file: create failed: ", err);
66+
defer file.close();
67+
68+
file.writeAll(unescaped) catch |err|
69+
return self.errResult("write_file: write failed: ", err);
70+
71+
const msg = std.fmt.allocPrint(self.allocator, "wrote {d} bytes to {s}", .{ unescaped.len, path }) catch
72+
return .{ .output = "wrote file", .is_error = false };
73+
return .{ .output = msg, .is_error = false };
74+
}
75+
76+
fn runBash(self: *ToolExecutor, input_json: []const u8) ToolResult {
77+
const command = json.extractField(input_json, "command") orelse
78+
return .{ .output = "error: missing 'command' field", .is_error = true };
79+
80+
const result = std.process.Child.run(.{
81+
.allocator = self.allocator,
82+
.argv = &.{ "sh", "-c", command },
83+
.max_output_bytes = 512 * 1024,
84+
}) catch |err| return self.errResult("bash: spawn failed: ", err);
85+
86+
defer self.allocator.free(result.stderr);
87+
88+
// If non-zero exit, combine stderr + stdout
89+
if (result.term.Exited != 0) {
90+
defer self.allocator.free(result.stdout);
91+
const combined = std.fmt.allocPrint(
92+
self.allocator,
93+
"exit code {d}\n{s}{s}",
94+
.{ result.term.Exited, result.stderr, result.stdout },
95+
) catch return .{ .output = "bash: error", .is_error = true };
96+
return .{ .output = combined, .is_error = true };
97+
}
98+
99+
return .{ .output = result.stdout, .is_error = false };
100+
}
101+
102+
fn runGrep(self: *ToolExecutor, input_json: []const u8) ToolResult {
103+
const pattern = json.extractField(input_json, "pattern") orelse
104+
return .{ .output = "error: missing 'pattern' field", .is_error = true };
105+
const path = json.extractField(input_json, "path") orelse ".";
106+
107+
const result = std.process.Child.run(.{
108+
.allocator = self.allocator,
109+
.argv = &.{ "grep", "-rn", pattern, path },
110+
.max_output_bytes = 512 * 1024,
111+
}) catch |err| return self.errResult("grep: spawn failed: ", err);
112+
113+
defer self.allocator.free(result.stderr);
114+
115+
// grep returns exit 1 for "no matches" — not an error
116+
if (result.stdout.len == 0) {
117+
self.allocator.free(result.stdout);
118+
return .{ .output = "no matches found", .is_error = false };
119+
}
120+
121+
return .{ .output = result.stdout, .is_error = false };
122+
}
123+
124+
fn errResult(self: *ToolExecutor, prefix: []const u8, err: anyerror) ToolResult {
125+
const msg = std.fmt.allocPrint(self.allocator, "{s}{s}", .{ prefix, @errorName(err) }) catch
126+
return .{ .output = prefix, .is_error = true };
127+
return .{ .output = msg, .is_error = true };
128+
}
129+
};
130+
131+
test "ToolName.fromString" {
132+
try std.testing.expectEqual(ToolName.read_file, ToolName.fromString("read_file").?);
133+
try std.testing.expectEqual(ToolName.bash, ToolName.fromString("bash").?);
134+
try std.testing.expect(ToolName.fromString("unknown") == null);
135+
}

0 commit comments

Comments
 (0)