Awesome-omni-skill zig-agents
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.
install
source · Clone the upstream repo
git clone https://github.com/diegosouzapw/awesome-omni-skill
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data-ai/zig-agents" ~/.claude/skills/diegosouzapw-awesome-omni-skill-zig-agents && rm -rf "$T"
manifest:
skills/data-ai/zig-agents/SKILL.mdsource content
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
for cleanup immediately after acquisitiondefer - Use
for error-path cleanuperrdefer - 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
- zig-best-practices/SKILL.md - Core Zig patterns
- zig-best-practices/DEBUGGING.md - Memory debugging