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
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ Sure, writing LLM agent loops is a solved problem and it's completely unnecessar

- **Type-safe Comptime Tool Registration**: Register standard Zig functions as agent tools. The compiler checks that your function parameters match the tool's descriptor structure using Zig's compile-time reflection.
- **ACP Server Integration**: Built-in JSON-RPC stdio server supporting the Agent Connection Protocol (ACP) for external client/IDE integration (`--acp`).
- **Context-Aware Tools & State Management**: Support for custom user-data contexts (`Tool.initWithContext`), session state tracking, and dynamic context injections across turns.
- **Built-in Agent Tools**: Standardized built-in tools (such as `agent.Tool.BuiltIn.Todo` for planning and tracking multi-step execution).
- **Context-Aware Tools & State Management**: Support for stateful tools with intrusive VTables (`Tool.wrapExecute`), session state tracking, and dynamic context injections across turns.
- **Built-in Agent Tools**: Standardized built-in tools (such as `agent.Tool.BuiltIn.Todo` for planning and tracking multi-step execution, and `agent.Tool.BuiltIn.Shell` for executing shell commands with multi-shell support: `sh`, `bash`, `zsh`, `cmd`, `powershell`, `pwsh`, and host `default`).
- **Incremental Streaming & Response Accumulator**: Built-in support for streaming model responses chunk-by-chunk with ANSI terminal markdown formatting.
- **Asynchronous & Concurrent Tool Execution**: Built on top of Zig's native non-blocking I/O event loop (`std.Io`). Tools run concurrently via `Io.Select`.
- **Async Zero-Dependency HTTP Client**: Direct communication with LLM backends asynchronously using Zig's native `std.http.Client`.
Expand All @@ -32,7 +32,7 @@ fn getWeather(allocator: std.mem.Allocator, zip_code: i64) ![]const u8 {
}

// Register it with the Agent
const weather_tool = Tool.init(.{
const weather_tool = Tool.fromFn(.{
.name = "get_weather",
.description = "Get the current weather for a given zip code.",
.parameters = &.{
Expand All @@ -46,7 +46,7 @@ const weather_tool = Tool.init(.{
}, getWeather);
```

You can also attach custom context to tools using `Tool.initWithContext` or include built-in tools like `agent.Tool.BuiltIn.Todo`.
You can also create stateful tools using `Tool.wrapExecute` and `Tool.VTable` or include built-in tools like `agent.Tool.BuiltIn.Todo` and `agent.Tool.BuiltIn.Shell.default`.

## Library Usage

Expand All @@ -55,18 +55,25 @@ To use COMA in your own project, initialize a provider and the agent loop:
```zig
var http_client: std.http.Client = .{ .allocator = allocator, .io = io };
var gemini_client = try provider.Gemini.init(allocator, &http_client, api_key);
var client = gemini_client.provider();
const client = gemini_client.provider();

const tools = [_]*const Tool{
&weather_tool,
&agent.Tool.BuiltIn.Todo,
&agent.Tool.BuiltIn.Shell.default,
};

const session_config: types.SessionConfig = .{
.model = selected_model,
.system_prompt = "You are a helpful assistant.",
.tools = &[_]Tool{ weather_tool, agent.Tool.BuiltIn.Todo },
.tools = &tools,
};

var session = try Session.init(allocator, io, client, session_config);
defer session.deinit();

const result = try session.executeTurn(.{ .prompt = "What is the weather in 90210?" });
var result = try session.executeTurn(.{ .prompt = "What is the weather in 90210?" });
defer result.deinit();
```

## Project Structure
Expand Down
2 changes: 1 addition & 1 deletion src/acp/Config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ const SessionConfig = @import("agent").types.SessionConfig;

const Config = @This();

provider: Provider,
provider: *Provider,
default_session_config: SessionConfig,
16 changes: 8 additions & 8 deletions src/acp/Server.zig
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ pub const AcpProtocolError = error{

/// Internal context payload passed to session streaming callbacks.
const ServerSessionContext = struct {
session_state: *SessionStorage.SessionState,
session_state: *const SessionStorage.SessionState,
json_rpc_writer: *JsonRpcWriter,
allocator: Allocator,
io: Io,
Expand Down Expand Up @@ -140,13 +140,13 @@ fn checkClientRequestValid(request: client_api.ClientRequest) AcpProtocolError!v

/// Callback handler for streaming turn updates, converting agent streaming chunks into JSON-RPC notifications.
fn handleTurnUpdate(ctx: ?*anyopaque, chunk: agent.types.StreamingChunk) void {
const stream_ctx: *ServerSessionContext = @ptrCast(@alignCast(ctx));
const stream_ctx: *const ServerSessionContext = @ptrCast(@alignCast(ctx));
const notification = converter.streamingChunkToNotification(stream_ctx.allocator, stream_ctx.session_state.id, chunk) catch return orelse return;
stream_ctx.json_rpc_writer.writeJsonObject(stream_ctx.io, notification, .{ .use_headers = false }) catch {};
}

/// Formats and writes a JSON-RPC error response using the provided JSON-RPC writer.
fn sendError(self: *Server, writer: *JsonRpcWriter, id: shared_api.RequestId, code: agent_api.JsonRpcErrorCode, message: []const u8) !void {
fn sendError(self: *const Server, writer: *JsonRpcWriter, id: shared_api.RequestId, code: agent_api.JsonRpcErrorCode, message: []const u8) !void {
try writer.writeJsonObject(self.io, agent_api.AgentErrorResponse{
.id = id,
.@"error" = .{
Expand All @@ -157,7 +157,7 @@ fn sendError(self: *Server, writer: *JsonRpcWriter, id: shared_api.RequestId, co
}

/// Handles `initialize` request negotiation and writes JSON-RPC `initialize` response.
fn handleInitialize(self: *Server, writer: *JsonRpcWriter, id: shared_api.RequestId, params: client_api.InitializeRequest) !void {
fn handleInitialize(self: *const Server, writer: *JsonRpcWriter, id: shared_api.RequestId, params: client_api.InitializeRequest) !void {
_ = params;
const reply: agent_api.AgentResponse = .{
.id = id,
Expand Down Expand Up @@ -202,7 +202,7 @@ fn handleSessionNew(self: *Server, writer: *JsonRpcWriter, id: shared_api.Reques
/// Wrapper for `handleSessionPrompt` that allows it to be called from the main async loop.
///
/// Takes ownership of `parse_result`, deinit-ing it upon completion.
fn handleSessionPromptFireAndForget(self: *Server, writer: *JsonRpcWriter, parse_result: std.json.Parsed(client_api.ClientRequest)) Io.Cancelable!void {
fn handleSessionPromptFireAndForget(self: *const Server, writer: *JsonRpcWriter, parse_result: std.json.Parsed(client_api.ClientRequest)) Io.Cancelable!void {
defer parse_result.deinit();
const client_request = parse_result.value;
self.handleSessionPrompt(writer, client_request.id, client_request.params.session_prompt) catch |err| {
Expand All @@ -215,7 +215,7 @@ fn handleSessionPromptFireAndForget(self: *Server, writer: *JsonRpcWriter, parse
/// TODO(razza): This method currently assume that only one prompt will ever be active for a given session at a time.
/// This gurantee does not exist. We should either buffer the prompts or block/lock waiting to acquire exclusive access
/// to the session.
fn handleSessionPrompt(self: *Server, writer: *JsonRpcWriter, id: shared_api.RequestId, params: client_api.PromptRequest) !void {
fn handleSessionPrompt(self: *const Server, writer: *JsonRpcWriter, id: shared_api.RequestId, params: client_api.PromptRequest) !void {
const prompt_blocks = params.prompt;
if (prompt_blocks.len == 0) {
try self.sendError(writer, id, .invalid_params, "Prompt array cannot be empty");
Expand Down Expand Up @@ -339,9 +339,9 @@ test "Server prompt handling - multiple items in prompt array" {
defer mock_provider.deinit();
const prov = mock_provider.provider();

const step_result = testing.MockProvider.stepResult(&.{.{ .text = "Response text" }}, &.{}, &.{});
var step_result = testing.MockProvider.stepResult(&.{.{ .text = "Response text" }}, &.{}, &.{});
const outcomes = [_](llm.Provider.ProviderError!llm.types.StepOutcome){
.{ .result = step_result, .continuation = testing.MockProvider.stepContinuation() },
.{ .result = &step_result, .continuation = testing.MockProvider.stepContinuation() },
};
mock_provider.execute_step_results = &outcomes;

Expand Down
10 changes: 5 additions & 5 deletions src/acp/client_api.zig
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,9 @@ pub const NewSessionRequest = struct {
/// These expand the session's filesystem scope without changing `cwd`, which
/// remains the base for relative paths. When omitted or empty, no
/// additional roots are activated for the new session.
additionalDirectories: ?[][]const u8 = null,
additionalDirectories: ?[]const []const u8 = null,
/// List of MCP (Model Context Protocol) servers the agent should connect to.
mcpServers: []McpServer,
mcpServers: []const McpServer,
};

/// MCP server transport types.
Expand Down Expand Up @@ -217,9 +217,9 @@ pub const McpServerStdio = struct {
/// Absolute path to the MCP server executable.
command: []const u8,
/// Command-line arguments to pass to the MCP server.
args: [][]const u8,
args: []const []const u8,
/// Environment variables to set when launching the MCP server.
env: []EnvVariable,
env: []const EnvVariable,
};

/// An environment variable to set when launching an MCP server.
Expand Down Expand Up @@ -252,7 +252,7 @@ pub const PromptRequest = struct {
/// When available, [`ContentBlock::Resource`] is preferred
/// as it avoids extra round-trips and allows the message to include
/// pieces of context from sources the agent may not have access to.
prompt: []shared_api.ContentBlock,
prompt: []const shared_api.ContentBlock,
};

test "json parse initialize ClientRequest" {
Expand Down
Loading
Loading