Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2205,4 +2205,22 @@ pub fn build(b: *std.Build) void {
// Also run as part of build step
// b.getInstallStep().dependOn(&run_registry_export.step);

// ═══════════════════════════════════════════════════════════════════════════════
// TRI-API — Direct Anthropic API Agent (Issue #60)
// ═══════════════════════════════════════════════════════════════════════════════

const tri_api = b.addExecutable(.{
.name = "tri-api",
.root_module = b.createModule(.{
.root_source_file = b.path("src/tri-api/main.zig"),
.target = target,
.optimize = optimize,
}),
});
b.installArtifact(tri_api);
const run_tri_api = b.addRunArtifact(tri_api);
if (b.args) |args| run_tri_api.addArgs(args);
const tri_api_step = b.step("tri-api", "Run TRI-API — Direct Anthropic API Agent");
tri_api_step.dependOn(&run_tri_api.step);

}
203 changes: 203 additions & 0 deletions src/tri-api/main.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// main.zig — TRI-API: Direct Anthropic API agentic loop
// No claude CLI dependency. Talks to api.anthropic.com/v1/messages directly.
// Self-contained in src/tri-api/. Issue #60.
const std = @import("std");
const proto = @import("tool_protocol.zig");
const executor = @import("tool_executor.zig");

const api_url = "https://api.anthropic.com/v1/messages";
const api_version = "2023-06-01";
const max_turns = 20;
const default_model = "claude-sonnet-4-20250514";

pub fn main() !void {
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();

// Read API key
const api_key = std.process.getEnvVarOwned(allocator, "ANTHROPIC_API_KEY") catch {
std.debug.print("error: ANTHROPIC_API_KEY not set\n", .{});
std.process.exit(1);
};
defer allocator.free(api_key);

// Parse CLI args: [--model <model>] <prompt...>
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);

var model: []const u8 = default_model;
var prompt_start: usize = 1; // skip argv[0]

if (args.len > 2 and std.mem.eql(u8, args[1], "--model")) {
model = args[2];
prompt_start = 3;
}

if (prompt_start >= args.len) {
std.debug.print("usage: tri-api [--model <model>] <prompt>\n", .{});
std.process.exit(1);
}

// Join remaining args as prompt
const prompt = std.mem.join(allocator, " ", args[prompt_start..]) catch {
std.debug.print("error: out of memory\n", .{});
std.process.exit(1);
};
defer allocator.free(prompt);

std.debug.print("[tri-api] model={s} prompt={d} chars\n", .{ model, prompt.len });

// Build conversation: messages accumulate across turns
var messages = std.ArrayList(u8).empty;
defer messages.deinit(allocator);

// Initial user message
try messages.appendSlice(allocator, "[{\"role\":\"user\",\"content\":\"");
try proto.writeJsonEscaped(messages.writer(allocator), prompt);
try messages.appendSlice(allocator, "\"}");

var tool_exec = executor.ToolExecutor{ .allocator = allocator };
var total_input_tokens: u32 = 0;
var total_output_tokens: u32 = 0;

// Agentic loop
var turn: u32 = 0;
while (turn < max_turns) : (turn += 1) {
// Close messages array
var request_body = std.ArrayList(u8).empty;
defer request_body.deinit(allocator);

try request_body.appendSlice(allocator, "{\"model\":\"");
try request_body.appendSlice(allocator, model);
try request_body.appendSlice(allocator, "\",\"max_tokens\":8192,\"tools\":");
try proto.writeToolDefinitions(request_body.writer(allocator));
try request_body.appendSlice(allocator, ",\"messages\":");
try request_body.appendSlice(allocator, messages.items);
try request_body.appendSlice(allocator, "]}");

std.debug.print("[tri-api] turn {d}: sending {d} bytes...\n", .{ turn + 1, request_body.items.len });

// POST to Anthropic API
const response_body = httpPost(allocator, api_key, request_body.items) catch |err| {
std.debug.print("[tri-api] HTTP error: {s}\n", .{@errorName(err)});
break;
};
defer allocator.free(response_body);

// Parse response
var parsed = proto.parseResponse(allocator, response_body);
defer parsed.deinit(allocator);

total_input_tokens += parsed.input_tokens;
total_output_tokens += parsed.output_tokens;

// Process content blocks
var has_tool_use = false;

// Build assistant message for conversation history
try messages.appendSlice(allocator, ",{\"role\":\"assistant\",\"content\":");
try messages.appendSlice(allocator, extractContentArray(response_body) orelse "[]");
try messages.appendSlice(allocator, "}");

for (parsed.blocks.items) |block| {
switch (block) {
.text => |text| {
const stdout_file = std.fs.File.stdout();
var write_buf: [4096]u8 = undefined;
var w = stdout_file.writer(&write_buf);
std.Io.Writer.writeAll(&w.interface, text) catch {};
std.Io.Writer.writeAll(&w.interface, "\n") catch {};
w.end() catch {};
},
.tool_use => |tool| {
has_tool_use = true;
std.debug.print("[tri-api] tool: {s}({s})\n", .{ tool.name, tool.id });

const tool_name = executor.ToolName.fromString(tool.name) orelse {
std.debug.print("[tri-api] unknown tool: {s}\n", .{tool.name});
continue;
};

const result = tool_exec.execute(tool_name, tool.input_json);

// Append tool result to messages
try messages.appendSlice(allocator, ",{\"role\":\"user\",\"content\":[");
try proto.writeToolResult(messages.writer(allocator), tool.id, result.output, result.is_error);
try messages.appendSlice(allocator, "]}");
},
}
}

// Check stop condition
if (std.mem.eql(u8, parsed.stop_reason, "end_turn") or !has_tool_use) {
std.debug.print("[tri-api] done: {s}\n", .{parsed.stop_reason});
break;
}
}

std.debug.print("[tri-api] {d} turns, {d} input + {d} output tokens\n", .{ turn + 1, total_input_tokens, total_output_tokens });
}

/// Extract the raw "content":[...] array from response body.
fn extractContentArray(body: []const u8) ?[]const u8 {
const needle = "\"content\":[";
const idx = std.mem.indexOf(u8, body, needle) orelse return null;
const start = idx + "\"content\":".len;
// Find matching ]
var depth: u32 = 0;
var end = start;
var in_string = false;
while (end < body.len) : (end += 1) {
if (in_string) {
if (body[end] == '"' and (end == 0 or body[end - 1] != '\\')) in_string = false;
continue;
}
switch (body[end]) {
'"' => in_string = true,
'[' => depth += 1,
']' => {
depth -= 1;
if (depth == 0) return body[start .. end + 1];
},
else => {},
}
}
return null;
}

/// POST JSON to Anthropic Messages API using Zig 0.15 std.http.Client.
fn httpPost(allocator: std.mem.Allocator, api_key: []const u8, body: []const u8) ![]const u8 {
var client = std.http.Client{ .allocator = allocator };
defer client.deinit();

const uri = std.Uri.parse(api_url) catch unreachable;

var req = client.request(.POST, uri, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "application/json" },
.{ .name = "x-api-key", .value = api_key },
.{ .name = "anthropic-version", .value = api_version },
},
}) catch return error.ConnectionFailed;
defer req.deinit();

req.transfer_encoding = .{ .content_length = body.len };
var body_writer = req.sendBodyUnflushed(&.{}) catch return error.ConnectionFailed;
body_writer.writer.writeAll(body) catch return error.ConnectionFailed;
body_writer.end() catch return error.ConnectionFailed;
if (req.connection) |conn| conn.flush() catch {};

var redirect_buf: [0]u8 = .{};
var response = req.receiveHead(&redirect_buf) catch return error.ConnectionFailed;

if (@intFromEnum(response.head.status) >= 400) {
std.debug.print("[tri-api] API status: {d}\n", .{@intFromEnum(response.head.status)});
}

var transfer_buf: [8192]u8 = undefined;
var reader = response.reader(&transfer_buf);
const resp_body = reader.allocRemaining(allocator, std.Io.Limit.limited(10 * 1024 * 1024)) catch return error.OutOfMemory;

return resp_body;
}
135 changes: 135 additions & 0 deletions src/tri-api/tool_executor.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// tool_executor.zig — Execute tools (read_file, write_file, bash, grep) via std
// Self-contained: no cross-directory imports. Uses std.fs + std.process.Child only.
const std = @import("std");
const json = @import("tool_protocol.zig");

pub const ToolName = enum {
read_file,
write_file,
bash,
grep,

pub fn fromString(name: []const u8) ?ToolName {
if (std.mem.eql(u8, name, "read_file")) return .read_file;
if (std.mem.eql(u8, name, "write_file")) return .write_file;
if (std.mem.eql(u8, name, "bash")) return .bash;
if (std.mem.eql(u8, name, "grep")) return .grep;
return null;
}
};

pub const ToolResult = struct {
output: []const u8,
is_error: bool,
};

pub const ToolExecutor = struct {
allocator: std.mem.Allocator,

pub fn execute(self: *ToolExecutor, name: ToolName, input_json: []const u8) ToolResult {
return switch (name) {
.read_file => self.readFile(input_json),
.write_file => self.writeFile(input_json),
.bash => self.runBash(input_json),
.grep => self.runGrep(input_json),
};
}

fn readFile(self: *ToolExecutor, input_json: []const u8) ToolResult {
const path = json.extractField(input_json, "path") orelse
return .{ .output = "error: missing 'path' field", .is_error = true };

const file = std.fs.cwd().openFile(path, .{}) catch |err|
return self.errResult("read_file: open failed: ", err);

defer file.close();

const content = file.readToEndAlloc(self.allocator, 512 * 1024) catch |err|
return self.errResult("read_file: read failed: ", err);

return .{ .output = content, .is_error = false };
}

fn writeFile(self: *ToolExecutor, input_json: []const u8) ToolResult {
const path = json.extractField(input_json, "path") orelse
return .{ .output = "error: missing 'path' field", .is_error = true };
const content = json.extractField(input_json, "content") orelse
return .{ .output = "error: missing 'content' field", .is_error = true };

// Unescape JSON string content (handle \n, \t, \\, \")
const unescaped = json.unescapeString(self.allocator, content) catch
return .{ .output = "error: unescape failed", .is_error = true };
defer self.allocator.free(unescaped);

const file = std.fs.cwd().createFile(path, .{}) catch |err|
return self.errResult("write_file: create failed: ", err);
defer file.close();

file.writeAll(unescaped) catch |err|
return self.errResult("write_file: write failed: ", err);

const msg = std.fmt.allocPrint(self.allocator, "wrote {d} bytes to {s}", .{ unescaped.len, path }) catch
return .{ .output = "wrote file", .is_error = false };
return .{ .output = msg, .is_error = false };
}

fn runBash(self: *ToolExecutor, input_json: []const u8) ToolResult {
const command = json.extractField(input_json, "command") orelse
return .{ .output = "error: missing 'command' field", .is_error = true };

const result = std.process.Child.run(.{
.allocator = self.allocator,
.argv = &.{ "sh", "-c", command },
.max_output_bytes = 512 * 1024,
}) catch |err| return self.errResult("bash: spawn failed: ", err);

defer self.allocator.free(result.stderr);

// If non-zero exit, combine stderr + stdout
if (result.term.Exited != 0) {
defer self.allocator.free(result.stdout);
const combined = std.fmt.allocPrint(
self.allocator,
"exit code {d}\n{s}{s}",
.{ result.term.Exited, result.stderr, result.stdout },
) catch return .{ .output = "bash: error", .is_error = true };
return .{ .output = combined, .is_error = true };
}

return .{ .output = result.stdout, .is_error = false };
}

fn runGrep(self: *ToolExecutor, input_json: []const u8) ToolResult {
const pattern = json.extractField(input_json, "pattern") orelse
return .{ .output = "error: missing 'pattern' field", .is_error = true };
const path = json.extractField(input_json, "path") orelse ".";

const result = std.process.Child.run(.{
.allocator = self.allocator,
.argv = &.{ "grep", "-rn", pattern, path },
.max_output_bytes = 512 * 1024,
}) catch |err| return self.errResult("grep: spawn failed: ", err);

defer self.allocator.free(result.stderr);

// grep returns exit 1 for "no matches" — not an error
if (result.stdout.len == 0) {
self.allocator.free(result.stdout);
return .{ .output = "no matches found", .is_error = false };
}

return .{ .output = result.stdout, .is_error = false };
}

fn errResult(self: *ToolExecutor, prefix: []const u8, err: anyerror) ToolResult {
const msg = std.fmt.allocPrint(self.allocator, "{s}{s}", .{ prefix, @errorName(err) }) catch
return .{ .output = prefix, .is_error = true };
return .{ .output = msg, .is_error = true };
}
};

test "ToolName.fromString" {
try std.testing.expectEqual(ToolName.read_file, ToolName.fromString("read_file").?);
try std.testing.expectEqual(ToolName.bash, ToolName.fromString("bash").?);
try std.testing.expect(ToolName.fromString("unknown") == null);
}
Loading
Loading