Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
c73223d
feat: scaffold agent and tool modules with build system integration
mrazza Jul 4, 2026
1f23015
feat: implement type-safe tool execution with dynamic argument mappin…
mrazza Jul 6, 2026
7deedb3
refactor: update root test structure and include standard library exp…
mrazza Jul 6, 2026
1d7e204
refactor: enforce string return types for tools and clarify memory ow…
mrazza Jul 6, 2026
7bcbd35
refactor: support named, optional, and unordered tool arguments with …
mrazza Jul 6, 2026
338c324
refactor: remove stale comments regarding TurnResult lifecycle manage…
mrazza Jul 8, 2026
8fde026
refactor: unify LLM step execution output into a single StepOutcome s…
mrazza Jul 8, 2026
b177145
fix: prevent memory leak in Agent loop by deinitializing tool outcome…
mrazza Jul 8, 2026
8e383af
refactor: simplify Provider interface by importing StepOutcome and St…
mrazza Jul 8, 2026
c117c54
refactor: rename last_step to prev_continuation and update Agent turn…
mrazza Jul 10, 2026
ac2e736
refactor: add initTakingResultOwnership to ToolResult to remove an un…
mrazza Jul 10, 2026
23332cd
refactor: alias std.mem.Allocator to Allocator in Agent.zig
mrazza Jul 10, 2026
c40e215
feat: implement streaming support for agent turns and update CLI to s…
mrazza Jul 10, 2026
9d05cbc
feat: unify agent streaming and tool result updates via StreamingChun…
mrazza Jul 11, 2026
2cebef9
feat: display tool call arguments and clean up TypeScript execution logs
mrazza Jul 11, 2026
b0f089b
docs: add comprehensive doc comments to agent types and structures
mrazza Jul 11, 2026
f3f0b0b
refactor: update Agent.executeTurn signature and align callback type …
mrazza Jul 11, 2026
b03c256
refactor: inline tool definitions within the tool registration array
mrazza Jul 11, 2026
ac07a79
refactor: inject allocator and io dependencies into Agent to simplify…
mrazza Jul 11, 2026
a004b87
refactor: implement init function for Agent and update usage in tests
mrazza Jul 11, 2026
d4afe53
feat: implement concurrent tool execution using async I/O and remove …
mrazza Jul 11, 2026
81ae2c6
feat: include tool call and result IDs in console output formatting
mrazza Jul 11, 2026
a9928cd
refactor: remove global IO state by passing Io dependency explicitly …
mrazza Jul 11, 2026
8bd1733
refactor: replace generic anyerror with specific ToolError in tool ex…
mrazza Jul 11, 2026
d26e1bd
fix: update tool error return type to ToolError.ToolNotFound in Agent…
mrazza Jul 11, 2026
c1d42aa
refactor: update Agent execution methods to return explicit AgentErro…
mrazza Jul 11, 2026
b43b9fd
refactor: implement tool execution using Io.Select for improved futur…
mrazza Jul 11, 2026
bf183dc
refactor: bundle tool call id and name into ToolCallDelta for improve…
mrazza Jul 11, 2026
2c88724
refactor: simplify delta conversion and introduce TransientDelta to m…
mrazza Jul 11, 2026
03a97d3
refactor: move StepAccumulator and TransientDelta to a separate accum…
mrazza Jul 11, 2026
ed8a187
docs: add doc comments to Agent public methods for improved API clarity
mrazza Jul 11, 2026
3d33390
refactor: consolidate test suite creation into a centralized helper f…
mrazza Jul 11, 2026
295eb32
test: add comprehensive unit tests for tool execution, streaming prov…
mrazza Jul 11, 2026
bb11e3c
refactor: clean up whitespace and formatting in Agent.zig structures
mrazza Jul 11, 2026
7a70cfd
refactor: replace tool argument hash map with linear search and impro…
mrazza Jul 11, 2026
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: 21 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ pub fn build(b: *std.Build) void {
},
});

const agent = b.addModule("agent", .{
.root_source_file = b.path("src/agent/root.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "llm", .module = llm },
.{ .name = "provider", .module = provider },
.{ .name = "testing", .module = testing },
},
});

// This creates a module, which represents a collection of source files alongside
// some compilation options, such as optimization mode and linked system libraries.
// Zig modules are the preferred way of making Zig code available to consumers.
Expand All @@ -66,6 +77,7 @@ pub fn build(b: *std.Build) void {
.imports = &.{
.{ .name = "llm", .module = llm },
.{ .name = "provider", .module = provider },
.{ .name = "agent", .module = agent },
},
});

Expand Down Expand Up @@ -109,6 +121,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "coma", .module = mod },
.{ .name = "llm", .module = llm },
.{ .name = "provider", .module = provider },
.{ .name = "agent", .module = agent },
},
}),
});
Expand Down Expand Up @@ -171,6 +184,11 @@ pub fn build(b: *std.Build) void {
});
const run_provider_tests = b.addRunArtifact(provider_tests);

const agent_tests = b.addTest(.{
.root_module = agent,
});
const run_agent_test = b.addRunArtifact(agent_tests);

// Creates an executable that will run `test` blocks from the llm module.
const llm_test_module = b.createModule(.{
.root_source_file = b.path("src/llm/root.tests.zig"),
Expand Down Expand Up @@ -199,6 +217,7 @@ pub fn build(b: *std.Build) void {
test_step.dependOn(&run_mod_tests.step);
test_step.dependOn(&run_exe_tests.step);
test_step.dependOn(&run_provider_tests.step);
test_step.dependOn(&run_agent_test.step);
test_step.dependOn(&run_llm_tests.step);
test_step.dependOn(&run_testing_tests.step);

Expand All @@ -207,6 +226,7 @@ pub fn build(b: *std.Build) void {
mod_tests,
exe_tests,
provider_tests,
agent_tests,
llm_tests,
testing_tests,
};
Expand All @@ -221,6 +241,7 @@ pub fn build(b: *std.Build) void {
"kcov-out/suite_2",
"kcov-out/suite_3",
"kcov-out/suite_4",
"kcov-out/suite_5",
});
for (test_suites, 0..) |test_exe, i| {
const out_dir = b.fmt("kcov-out/suite_{d}", .{i});
Expand Down
212 changes: 212 additions & 0 deletions src/agent/Agent.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
const std = @import("std");
Comment thread
mrazza marked this conversation as resolved.
const llm = @import("llm");
const Tool = @import("./Tool.zig");
const types = @import("./types.zig");

const Agent = @This();

provider: llm.Provider,
tools: []const Tool,
session_config: llm.types.SessionConfig,
prev_continuation: ?llm.types.StepContinuation,

pub fn deinit(self: *Agent) void {
if (self.prev_continuation) |*ls| {
ls.deinit();
self.prev_continuation = null;
}
}

pub fn executeTurn(self: *Agent, allocator: std.mem.Allocator, turn: types.Turn) !types.TurnResult {
var next_steps: std.ArrayList(llm.types.Step) = .empty;
defer next_steps.deinit(allocator);
try next_steps.append(allocator, .{ .prompt = turn.prompt });

var intermediate_results: std.ArrayList(types.IntermediateStepResult) = .empty;
defer {
// `intermediate_results` will own the memory for all non-final steps; that is, all steps
// that are model or tool outputs but not the initial user input or final result.
// These values will, in the event of a non-error, have their ownership transfered to
// the returned `TurnResult`.
for (intermediate_results.items) |*ir| {
ir.deinit();
}
intermediate_results.deinit(allocator);
}

while (true) {
const step_outcome = try self.provider.executeStep(
allocator,
self.session_config,
next_steps.items,
self.prev_continuation,
);
next_steps.clearRetainingCapacity();

var step_result = step_outcome.result;
const step_continuation = step_outcome.continuation;

if (self.prev_continuation) |*old_continuation| old_continuation.deinit();
self.prev_continuation = step_continuation;

if (step_result.tool_calls.len > 0) {
intermediate_results.append(allocator, .{ .step_result = step_result }) catch |err| {
step_result.deinit();
return err;
};
for (step_result.tool_calls) |tool_call| {
const tool = for (self.tools) |t| {
if (std.mem.eql(u8, t.descriptor.name, tool_call.name)) {
break t;
}
} else {
return error.ToolNotFound;
};

var tool_result = try tool.execute(allocator, tool_call.id, tool_call.arguments);
intermediate_results.append(allocator, .{ .tool_result = tool_result }) catch |err| {
tool_result.deinit();
return err;
};
try next_steps.append(allocator, .{ .tool_result = tool_result });
}
} else {
return .{
.allocator = allocator,
.final_step = step_result,
.intermediate_steps = try intermediate_results.toOwnedSlice(allocator),
};
}
}
}

const testing_pkg = @import("testing");
threadlocal var test_mock_provider: ?*testing_pkg.MockProvider = null;

test "Agent.executeTurn - no tool calls" {
const allocator = std.testing.allocator;
var mock_provider = testing_pkg.MockProvider{};
const prov = mock_provider.provider();

const mock_model = llm.types.Model{
.id = "mock-model",
.display_name = "Mock Model",
};

var agent = Agent{
.provider = prov,
.tools = &.{},
.session_config = .{
.model = mock_model,
.tools = &.{},
},
.prev_continuation = null,
};
defer agent.deinit();

mock_provider.execute_step_result = llm.types.StepResult{
.model_output = &.{.{ .text = "Hello user!" }},
.thoughts = &.{},
.tool_calls = &.{},
.ptr = &mock_provider,
.vtable = &testing_pkg.MockProvider.mock_step_vtable,
};
var mock_continuation: testing_pkg.MockProvider.MockStepContinuation = .{};
mock_provider.execute_step_continuation = mock_continuation.stepContinuation();

const turn = types.Turn{ .prompt = "Hi agent" };

var result = try agent.executeTurn(allocator, turn);
defer result.deinit();

try std.testing.expectEqual(@as(usize, 1), mock_provider.execute_step_calls);
try std.testing.expect(agent.prev_continuation != null);
try std.testing.expectEqualStrings("Hello user!", result.final_step.model_output[0].text);
}

const MockToolImpl = struct {
pub fn execute(allocator: std.mem.Allocator, val: i64) ![]const u8 {
if (test_mock_provider) |mp| {
mp.execute_step_result = llm.types.StepResult{
.model_output = &.{.{ .text = "Final output after tool" }},
.thoughts = &.{},
.tool_calls = &.{},
.ptr = mp,
.vtable = &testing_pkg.MockProvider.mock_step_vtable,
};
}
return try std.fmt.allocPrint(allocator, "Tool result for {d}", .{val});
}
};

test "Agent.executeTurn - executes tool call and runs again" {
const allocator = std.testing.allocator;
var mock_provider = testing_pkg.MockProvider{};
const prov = mock_provider.provider();
test_mock_provider = &mock_provider;
defer test_mock_provider = null;

const tool_desc = llm.types.Tool{
.name = "mock_tool",
.description = "A mock tool for testing",
.parameters = &.{
.{
.name = "val",
.description = "integer value",
.type = .integer,
.required = true,
},
},
};

const tool = Tool.init(tool_desc, MockToolImpl.execute);
const tools = &[_]Tool{tool};

const mock_model = llm.types.Model{
.id = "mock-model",
.display_name = "Mock Model",
};

var agent = Agent{
.provider = prov,
.tools = tools,
.session_config = .{
.model = mock_model,
.tools = &.{tool.descriptor},
},
.prev_continuation = null,
};
defer agent.deinit();

const args = [_]llm.types.Argument{
.{ .name = "val", .value = .{ .integer = 42 } },
};
const tool_calls = [_]llm.types.ToolCall{
.{
.id = "call-id-123",
.name = "mock_tool",
.arguments = @constCast(&args),
},
};

mock_provider.execute_step_result = llm.types.StepResult{
.model_output = &.{},
.thoughts = &.{},
.tool_calls = &tool_calls,
.ptr = &mock_provider,
.vtable = &testing_pkg.MockProvider.mock_step_vtable,
};
mock_provider.execute_step_continuation = llm.types.StepContinuation{
.ptr = &mock_provider,
.vtable = &testing_pkg.MockProvider.mock_continuation_vtable,
};

const turn = types.Turn{ .prompt = "Hi agent, run mock_tool" };

var result = try agent.executeTurn(allocator, turn);
defer result.deinit();

try std.testing.expectEqual(@as(usize, 2), mock_provider.execute_step_calls);
try std.testing.expect(agent.prev_continuation != null);
try std.testing.expectEqualStrings("Final output after tool", result.final_step.model_output[0].text);
}
Loading
Loading