| name | zig-agents |
| description | Patterns and best practices for building AI agents in Zig. Covers tool systems, context management, LLM providers, streaming responses, and session persistence. Use when implementing agent functionality. |
Zig Agent Development
Overview
This skill covers patterns for building AI agents in Zig, including:
- Tool system design and implementation
- Conversation context management
- LLM provider abstractions
- Streaming response handling
- Session persistence
- Agent loop patterns
Architecture
Agent
├── Config # API keys, model settings
├── Context # Conversation history
├── ToolRegistry # Available tools
├── Provider # LLM API abstraction
└── Session # Persistence layer
Agent Loop Pattern
The core agent loop follows this flow:
1. Add user message to context
2. Loop (max N iterations):
a. Call LLM with context + tools
b. Add assistant response to context
c. If tool calls present:
- Execute each tool
- Add tool results to context
- Continue loop
d. Else: break
3. Save session
Tool System
Tool Definition
pub const ToolContext = struct {
allocator: std.mem.Allocator,
config: Config,
};
pub const Tool = struct {
name: []const u8,
description: []const u8,
parameters: []const u8, // JSON Schema
execute: *const fn (ctx: ToolContext, arguments: []const u8) anyerror![]const u8,
};
Tool Registry
pub const ToolRegistry = struct {
tools: std.StringHashMap(Tool),
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator) ToolRegistry {
return .{
.tools = std.StringHashMap(Tool).init(allocator),
.allocator = allocator,
};
}
pub fn deinit(self: *ToolRegistry) void {
self.tools.deinit();
}
pub fn register(self: *ToolRegistry, tool: Tool) !void {
try self.tools.put(tool.name, tool);
}
pub fn get(self: *ToolRegistry, name: []const u8) ?Tool {
return self.tools.get(name);
}
};
Implementing Tools
Tools should:
- Parse JSON arguments
- Perform the operation
- Return a string result (allocated with ctx.allocator)
Example: File reading tool
pub fn read_file(ctx: ToolContext, arguments: []const u8) ![]const u8 {
const Args = struct { path: []const u8 };
const parsed = try std.json.parseFromSlice(Args, ctx.allocator, arguments, .{
.ignore_unknown_fields = true,
});
defer parsed.deinit();
const file = try std.fs.cwd().openFile(parsed.value.path, .{});
defer file.close();
return file.readToEndAlloc(ctx.allocator, 10 * 1024 * 1024);
}
Example: Web search tool with HTTP
pub fn web_search(ctx: ToolContext, arguments: []const u8) ![]const u8 {
const Args = struct { query: []const u8 };
const parsed = try std.json.parseFromSlice(Args, ctx.allocator, arguments, .{
.ignore_unknown_fields = true,
});
defer parsed.deinit();
const api_key = ctx.config.tools.web.search.apiKey orelse {
return try ctx.allocator.dupe(u8, "Error: Search API key not configured.");
};
var client = http.Client.init(ctx.allocator);
defer client.deinit();
// URL encode the query
var encoded = std.io.Writer.Allocating.init(ctx.allocator);
defer encoded.deinit();
try std.Uri.Component.formatQuery(.{ .raw = parsed.value.query }, &encoded.writer);
const url = try std.fmt.allocPrint(ctx.allocator,
"https://api.example.com/search?q={s}", .{encoded.written()});
defer ctx.allocator.free(url);
const headers = &[_]std.http.Header{
.{ .name = "Authorization", .value = api_key },
};
var response = client.get(url, headers) catch |err| {
return try std.fmt.allocPrint(ctx.allocator, "Error: {any}", .{err});
};
defer response.deinit();
return try ctx.allocator.dupe(u8, response.body);
}
Tool JSON Schema
Define parameters using JSON Schema for LLM consumption:
.parameters =
\\{"type": "object", "properties": {"path": {"type": "string", "description": "File path to read"}}, "required": ["path"]}
,
Context Management
Message Structure
pub const ToolCall = struct {
id: []const u8,
function_name: []const u8,
arguments: []const u8,
};
pub const Message = struct {
role: []const u8, // "system", "user", "assistant", "tool"
content: ?[]const u8 = null,
tool_call_id: ?[]const u8 = null,
tool_calls: ?[]ToolCall = null,
};
Context Implementation
pub const Context = struct {
messages: std.ArrayListUnmanaged(Message),
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator) Context {
return .{
.messages = .{},
.allocator = allocator,
};
}
pub fn deinit(self: *Context) void {
for (self.messages.items) |msg| {
self.freeMessage(msg);
}
self.messages.deinit(self.allocator);
}
pub fn add_message(self: *Context, msg: Message) !void {
// Duplicate all strings to ensure ownership
const duped = Message{
.role = try self.allocator.dupe(u8, msg.role),
.content = if (msg.content) |c| try self.allocator.dupe(u8, c) else null,
.tool_call_id = if (msg.tool_call_id) |id| try self.allocator.dupe(u8, id) else null,
.tool_calls = if (msg.tool_calls) |calls| try self.dupeToolCalls(calls) else null,
};
try self.messages.append(self.allocator, duped);
}
pub fn get_messages(self: *Context) []Message {
return self.messages.items;
}
fn dupeToolCalls(self: *Context, calls: []ToolCall) ![]ToolCall {
const result = try self.allocator.alloc(ToolCall, calls.len);
for (calls, 0..) |call, i| {
result[i] = .{
.id = try self.allocator.dupe(u8, call.id),
.function_name = try self.allocator.dupe(u8, call.function_name),
.arguments = try self.allocator.dupe(u8, call.arguments),
};
}
return result;
}
fn freeMessage(self: *Context, msg: Message) void {
self.allocator.free(msg.role);
if (msg.content) |c| self.allocator.free(c);
if (msg.tool_call_id) |id| self.allocator.free(id);
if (msg.tool_calls) |calls| {
for (calls) |call| {
self.allocator.free(call.id);
self.allocator.free(call.function_name);
self.allocator.free(call.arguments);
}
self.allocator.free(calls);
}
}
};
Provider Abstraction
Provider Interface
pub const ChatResponse = struct {
content: ?[]const u8,
tool_calls: ?[]ToolCall,
allocator: std.mem.Allocator,
pub fn deinit(self: *ChatResponse) void {
if (self.content) |c| self.allocator.free(c);
if (self.tool_calls) |calls| {
for (calls) |call| {
self.allocator.free(call.id);
self.allocator.free(call.function_name);
self.allocator.free(call.arguments);
}
self.allocator.free(calls);
}
}
};
Streaming Pattern
Use a callback for streaming chunks:
pub fn chatStream(
self: *Provider,
messages: []const Message,
model: []const u8,
on_chunk: fn ([]const u8) void,
) !ChatResponse {
// Build request
var req = try self.client.post(self.api_url, headers, body);
defer req.deinit();
// Stream response
var content = std.ArrayListUnmanaged(u8){};
var tool_calls = std.ArrayListUnmanaged(ToolCall){};
while (try req.readChunk()) |chunk| {
// Parse SSE data
const data = parseSSE(chunk);
if (data.content) |text| {
on_chunk(text); // Stream to user
try content.appendSlice(self.allocator, text);
}
if (data.tool_call) |tc| {
try tool_calls.append(self.allocator, tc);
}
}
return ChatResponse{
.content = content.toOwnedSlice(self.allocator),
.tool_calls = if (tool_calls.items.len > 0)
tool_calls.toOwnedSlice(self.allocator)
else
null,
.allocator = self.allocator,
};
}
SSE (Server-Sent Events) Parsing
fn parseSSELine(line: []const u8) ?[]const u8 {
if (std.mem.startsWith(u8, line, "data: ")) {
const data = line[6..];
if (std.mem.eql(u8, data, "[DONE]")) return null;
return data;
}
return null;
}
fn processStreamChunk(chunk: []const u8, buffer: *std.ArrayListUnmanaged(u8)) !?ParsedChunk {
try buffer.appendSlice(allocator, chunk);
// Find complete lines
while (std.mem.indexOf(u8, buffer.items, "\n")) |idx| {
const line = buffer.items[0..idx];
// Remove processed line from buffer
std.mem.copyForwards(u8, buffer.items, buffer.items[idx + 1 ..]);
buffer.shrinkRetainingCapacity(buffer.items.len - idx - 1);
if (parseSSELine(line)) |json_data| {
return try parseChunkJson(json_data);
}
}
return null;
}
Session Persistence
JSON-based Session Storage
const SESSION_DIR = ".sessions";
pub fn save(allocator: std.mem.Allocator, session_id: []const u8, messages: []Message) !void {
// Ensure directory exists
std.fs.cwd().makeDir(SESSION_DIR) catch |err| switch (err) {
error.PathAlreadyExists => {},
else => return err,
};
const path = try std.fmt.allocPrint(allocator, "{s}/{s}.json", .{ SESSION_DIR, session_id });
defer allocator.free(path);
const file = try std.fs.cwd().createFile(path, .{});
defer file.close();
var out = std.io.Writer.Allocating.init(allocator);
defer out.deinit();
try std.json.Stringify.value(messages, .{}, &out.writer);
try file.writeAll(out.written());
}
pub fn load(allocator: std.mem.Allocator, session_id: []const u8) ![]Message {
const path = try std.fmt.allocPrint(allocator, "{s}/{s}.json", .{ SESSION_DIR, session_id });
defer allocator.free(path);
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);
defer allocator.free(content);
const parsed = try std.json.parseFromSlice([]Message, allocator, content, .{
.ignore_unknown_fields = true,
});
// Note: caller must free parsed.value elements
return parsed.value;
}
Agent Implementation
Full Agent Structure
pub const Agent = struct {
config: Config,
allocator: std.mem.Allocator,
ctx: Context,
registry: ToolRegistry,
session_id: []const u8,
pub fn init(allocator: std.mem.Allocator, config: Config, session_id: []const u8) Agent {
var self = Agent{
.config = config,
.allocator = allocator,
.ctx = Context.init(allocator),
.registry = ToolRegistry.init(allocator),
.session_id = session_id,
};
// Load existing session
if (session.load(allocator, session_id)) |history| {
for (history) |msg| {
self.ctx.add_message(msg) catch {};
}
// Free loaded history (context dupes it)
freeLoadedHistory(allocator, history);
} else |_| {}
// Register tools
self.registerDefaultTools();
return self;
}
pub fn deinit(self: *Agent) void {
self.ctx.deinit();
self.registry.deinit();
}
pub fn run(self: *Agent, message: []const u8) !void {
try self.ctx.add_message(.{ .role = "user", .content = message });
var provider = Provider.init(self.allocator, self.config.api_key);
defer provider.deinit();
const tool_ctx = ToolContext{
.allocator = self.allocator,
.config = self.config,
};
var iterations: usize = 0;
const max_iterations = 5;
while (iterations < max_iterations) : (iterations += 1) {
var response = try provider.chatStream(
self.ctx.get_messages(),
self.config.model,
printChunk,
);
defer response.deinit();
try self.ctx.add_message(.{
.role = "assistant",
.content = response.content,
.tool_calls = response.tool_calls,
});
if (response.tool_calls) |calls| {
for (calls) |call| {
const result = try self.executeToolCall(tool_ctx, call);
defer self.allocator.free(result);
try self.ctx.add_message(.{
.role = "tool",
.content = result,
.tool_call_id = call.id,
});
}
continue;
}
break;
}
try session.save(self.allocator, self.session_id, self.ctx.get_messages());
}
fn executeToolCall(self: *Agent, ctx: ToolContext, call: ToolCall) ![]const u8 {
if (self.registry.get(call.function_name)) |tool| {
return tool.execute(ctx, call.arguments);
}
return std.fmt.allocPrint(self.allocator, "Error: Tool {s} not found", .{call.function_name});
}
fn registerDefaultTools(self: *Agent) void {
self.registry.register(.{
.name = "list_files",
.description = "List files in the current directory",
.parameters = "{}",
.execute = tools.list_files,
}) catch {};
// ... more tools
}
};
Error Handling
Tool Error Patterns
Tools should catch errors and return user-friendly messages:
pub fn safe_tool(ctx: ToolContext, arguments: []const u8) ![]const u8 {
const result = innerOperation(ctx, arguments) catch |err| {
return try std.fmt.allocPrint(ctx.allocator, "Error: {any}", .{err});
};
return result;
}
Agent Error Recovery
if (self.registry.get(call.function_name)) |tool| {
const result = tool.execute(tool_ctx, call.arguments) catch |err| {
return try std.fmt.allocPrint(self.allocator, "Tool error: {any}", .{err});
};
// ...
} else {
const error_msg = try std.fmt.allocPrint(
self.allocator,
"Error: Tool {s} not found",
.{call.function_name}
);
try self.ctx.add_message(.{
.role = "tool",
.content = error_msg,
.tool_call_id = call.id,
});
}
Testing Agents
Unit Testing Tools
const std = @import("std");
const testing = std.testing;
test "list_files returns files" {
const allocator = testing.allocator;
const ctx = ToolContext{
.allocator = allocator,
.config = Config.default(),
};
const result = try list_files(ctx, "{}");
defer allocator.free(result);
try testing.expect(result.len > 0);
}
Mocking Providers
const MockProvider = struct {
responses: []const []const u8,
call_count: usize = 0,
pub fn chatStream(
self: *MockProvider,
messages: []const Message,
model: []const u8,
on_chunk: fn ([]const u8) void,
) !ChatResponse {
_ = messages;
_ = model;
const response = self.responses[self.call_count];
self.call_count += 1;
on_chunk(response);
return ChatResponse{
.content = response,
.tool_calls = null,
.allocator = undefined, // Mock doesn't need cleanup
};
}
};
Best Practices
1. Memory Management
- Always use
defer for cleanup immediately after acquisition
- Use
errdefer for error-path cleanup
- Pass allocators explicitly to all functions
- Duplicate strings when storing in context to ensure ownership
2. Tool Design
- Single responsibility: One tool, one purpose
- Clear JSON schemas: Document all parameters
- Graceful error handling: Return error messages, don't crash
- Limit output size: Cap response length for LLM consumption
3. Context Management
- Duplicate all strings: Context owns its data
- Free on deinit: Clean up all allocations
- Session isolation: Each session is independent
4. Streaming
- Buffer incomplete chunks: SSE lines may span chunks
- Handle [DONE] signal: Mark end of stream
- Immediate output: Call on_chunk for real-time display
5. Provider Abstraction
- Interface consistency: Same signature for all providers
- Error translation: Convert HTTP errors to domain errors
- Timeout handling: Set reasonable request timeouts
See Also