Skip to content
Open
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
245 changes: 239 additions & 6 deletions src/agent/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2022,7 +2022,13 @@ pub const Agent = struct {
defer prompt_tools_arena.deinit();
const prompt_tools = try self.filterToolsForPromptText(prompt_tools_arena.allocator());
const prompt_is_streaming = self.stream_callback != null and self.stream_ctx != null and self.provider.supportsStreaming();
const prompt_native_tools_enabled = !prompt_is_streaming and self.provider.supportsNativeTools();
// Ask about the model this turn will actually use, not the
// configured default: a router or failover wrapper can route the
// request to a provider that cannot recover native tool calls.
const prompt_native_tools_enabled = if (prompt_is_streaming)
self.provider.supportsStreamingNativeToolsForModel(turn_model_name)
else
self.provider.supportsNativeTools();

const capabilities_section = capabilities_mod.buildPromptSection(
self.allocator,
Expand Down Expand Up @@ -2200,7 +2206,10 @@ pub const Agent = struct {

const timer_start = std_compat.time.milliTimestamp();
const is_streaming = self.stream_callback != null and self.stream_ctx != null and self.provider.supportsStreaming();
const native_tools_enabled = !is_streaming and self.provider.supportsNativeTools();
const native_tools_enabled = if (is_streaming)
self.provider.supportsStreamingNativeToolsForModel(turn_model_name)
else
self.provider.supportsNativeTools();
const include_reasoning = self.reasoning_mode != .off;

// Filter tool specs for this turn (arena-owned; may be self.tool_specs directly if no groups).
Expand All @@ -2216,7 +2225,7 @@ pub const Agent = struct {
turn_max_tokens,
);

// Call provider: streaming (no retries, no native tools) or blocking with retry
// Call provider: streaming (native tools only when parser-supported) or blocking with retry
var response: ChatResponse = undefined;
var response_attempt: u32 = 1;
providers.clearLastApiErrorDetail();
Expand All @@ -2231,7 +2240,7 @@ pub const Agent = struct {
.model = turn_model_name,
.temperature = self.temperature,
.max_tokens = request_max_tokens,
.tools = null,
.tools = if (native_tools_enabled) turn_tool_specs else null,
.timeout_secs = self.message_timeout_secs,
.reasoning_effort = self.reasoning_effort,
.include_reasoning = include_reasoning,
Expand Down Expand Up @@ -2268,7 +2277,7 @@ pub const Agent = struct {
.model = turn_model_name,
.temperature = self.temperature,
.max_tokens = retry_max_tokens,
.tools = null,
.tools = if (native_tools_enabled) turn_tool_specs else null,
.timeout_secs = self.message_timeout_secs,
.reasoning_effort = self.reasoning_effort,
.include_reasoning = include_reasoning,
Expand All @@ -2291,7 +2300,7 @@ pub const Agent = struct {
response = ChatResponse{
.content = stream_result.content,
.reasoning_content = stream_result.reasoning_content,
.tool_calls = &.{},
.tool_calls = stream_result.tool_calls,
.usage = stream_result.usage,
.model = stream_result.model,
};
Expand Down Expand Up @@ -10891,6 +10900,230 @@ test "Agent system prompt keeps parameters when streaming disables native tool s
try std.testing.expect(std.mem.indexOf(u8, captured, "mcp_secret_lookup") == null);
}

test "Agent system prompt omits parameters when streaming native tools are supported" {
const StreamingNativeToolCapture = struct {
captured_system: ?[]u8 = null,
saw_tools: bool = false,
capture_alloc: std.mem.Allocator,

fn chatWithSystem(_: *anyopaque, allocator_: std.mem.Allocator, _: ?[]const u8, _: []const u8, _: []const u8, _: f64) anyerror![]const u8 {
return allocator_.dupe(u8, "ok");
}

fn chat(_: *anyopaque, _: std.mem.Allocator, _: providers.ChatRequest, _: []const u8, _: f64) anyerror!providers.ChatResponse {
return error.ShouldNotUseBlockingChat;
}

fn supportsNativeTools(_: *anyopaque) bool {
return true;
}

fn supportsStreaming(_: *anyopaque) bool {
return true;
}

fn supportsStreamingNativeTools(_: *anyopaque) bool {
return true;
}

fn streamChat(
ptr: *anyopaque,
allocator_: std.mem.Allocator,
request: providers.ChatRequest,
model: []const u8,
_: f64,
callback: providers.StreamCallback,
callback_ctx: *anyopaque,
) anyerror!providers.StreamChatResult {
try std.testing.expect(request.tools != null);
const self: *@This() = @ptrCast(@alignCast(ptr));
self.saw_tools = request.tools.?.len > 0;
for (request.messages) |msg| {
if (msg.role == .system) {
if (self.captured_system) |old| self.capture_alloc.free(old);
self.captured_system = try self.capture_alloc.dupe(u8, msg.content);
break;
}
}
callback(callback_ctx, providers.StreamChunk.textDelta("ok"));
callback(callback_ctx, providers.StreamChunk.finalChunk());
return .{
.content = try allocator_.dupe(u8, "ok"),
.model = try allocator_.dupe(u8, model),
};
}

fn getName(_: *anyopaque) []const u8 {
return "streaming-native-tool-capture";
}

fn deinitFn(_: *anyopaque) void {}
};

const allocator = std.testing.allocator;
var provider_state = StreamingNativeToolCapture{ .capture_alloc = allocator };
defer if (provider_state.captured_system) |captured| allocator.free(captured);
const provider_vtable = Provider.VTable{
.chatWithSystem = StreamingNativeToolCapture.chatWithSystem,
.chat = StreamingNativeToolCapture.chat,
.supportsNativeTools = StreamingNativeToolCapture.supportsNativeTools,
.getName = StreamingNativeToolCapture.getName,
.deinit = StreamingNativeToolCapture.deinitFn,
.supports_streaming = StreamingNativeToolCapture.supportsStreaming,
.supports_streaming_native_tools = StreamingNativeToolCapture.supportsStreamingNativeTools,
.stream_chat = StreamingNativeToolCapture.streamChat,
};
const provider = Provider{ .ptr = @ptrCast(&provider_state), .vtable = &provider_vtable };

const runtime_tools = [_]Tool{
try makeMockFilterTool(allocator, "shell"),
try makeMockFilterTool(allocator, "mcp_secret_lookup"),
};
defer for (runtime_tools) |t| freeMockFilterTool(t, allocator);

var cfg = Config{
.workspace_dir = "/tmp",
.config_path = "/tmp/config.json",
.default_model = "openai/gpt-4.1-mini",
.allocator = allocator,
};
var noop = observability.NoopObserver{};
var agent = try Agent.fromConfig(allocator, &cfg, provider, &runtime_tools, null, noop.observer());
defer agent.deinit();
agent.tool_filter_groups = &.{
.{ .mode = .dynamic, .tools = &.{"mcp_secret_*"}, .keywords = &.{"secret"} },
};

const StreamSink = struct {
fn onChunk(_: *anyopaque, _: providers.StreamChunk) void {}
};
var stream_ctx: u8 = 0;
agent.stream_callback = StreamSink.onChunk;
agent.stream_ctx = @ptrCast(&stream_ctx);

const response = try agent.turn("hello");
defer allocator.free(response);

try std.testing.expect(provider_state.saw_tools);
try std.testing.expect(provider_state.captured_system != null);
const captured = provider_state.captured_system.?;
try std.testing.expect(std.mem.indexOf(u8, captured, "**shell**: shell") != null);
try std.testing.expect(std.mem.indexOf(u8, captured, "Parameters: `{}`") == null);
try std.testing.expect(std.mem.indexOf(u8, captured, "mcp_secret_lookup") == null);
}

test "Agent executes native tool calls returned from streaming provider" {
const StreamingToolCallProvider = struct {
calls: usize = 0,

fn chatWithSystem(_: *anyopaque, allocator_: std.mem.Allocator, _: ?[]const u8, _: []const u8, _: []const u8, _: f64) anyerror![]const u8 {
return allocator_.dupe(u8, "ok");
}

fn chat(_: *anyopaque, _: std.mem.Allocator, _: providers.ChatRequest, _: []const u8, _: f64) anyerror!providers.ChatResponse {
return error.ShouldNotUseBlockingChat;
}

fn supportsNativeTools(_: *anyopaque) bool {
return true;
}

fn supportsStreaming(_: *anyopaque) bool {
return true;
}

fn supportsStreamingNativeTools(_: *anyopaque) bool {
return true;
}

fn streamChat(
ptr: *anyopaque,
allocator_: std.mem.Allocator,
request: providers.ChatRequest,
model: []const u8,
_: f64,
callback: providers.StreamCallback,
callback_ctx: *anyopaque,
) anyerror!providers.StreamChatResult {
try std.testing.expect(request.tools != null);
const self: *@This() = @ptrCast(@alignCast(ptr));
self.calls += 1;

if (self.calls == 1) {
const tool_calls = try allocator_.alloc(providers.ToolCall, 1);
tool_calls[0] = .{
.id = try allocator_.dupe(u8, "call_stream_shell"),
.name = try allocator_.dupe(u8, "shell"),
.arguments = try allocator_.dupe(u8, "{}"),
};
callback(callback_ctx, providers.StreamChunk.textDelta("calling shell"));
callback(callback_ctx, providers.StreamChunk.finalChunk());
return .{
.content = try allocator_.dupe(u8, "calling shell"),
.tool_calls = tool_calls,
.model = try allocator_.dupe(u8, model),
};
}

callback(callback_ctx, providers.StreamChunk.textDelta("done"));
callback(callback_ctx, providers.StreamChunk.finalChunk());
return .{
.content = try allocator_.dupe(u8, "done"),
.model = try allocator_.dupe(u8, model),
};
}

fn getName(_: *anyopaque) []const u8 {
return "streaming-tool-call-provider";
}

fn deinitFn(_: *anyopaque) void {}
};

const allocator = std.testing.allocator;
var provider_state = StreamingToolCallProvider{};
const provider_vtable = Provider.VTable{
.chatWithSystem = StreamingToolCallProvider.chatWithSystem,
.chat = StreamingToolCallProvider.chat,
.supportsNativeTools = StreamingToolCallProvider.supportsNativeTools,
.getName = StreamingToolCallProvider.getName,
.deinit = StreamingToolCallProvider.deinitFn,
.supports_streaming = StreamingToolCallProvider.supportsStreaming,
.supports_streaming_native_tools = StreamingToolCallProvider.supportsStreamingNativeTools,
.stream_chat = StreamingToolCallProvider.streamChat,
};
const provider = Provider{ .ptr = @ptrCast(&provider_state), .vtable = &provider_vtable };

const runtime_tools = [_]Tool{
try makeMockFilterTool(allocator, "shell"),
};
defer for (runtime_tools) |t| freeMockFilterTool(t, allocator);

var cfg = Config{
.workspace_dir = "/tmp",
.config_path = "/tmp/config.json",
.default_model = "openai/gpt-4.1-mini",
.allocator = allocator,
};
cfg.agent.max_tool_iterations = 2;
var noop = observability.NoopObserver{};
var agent = try Agent.fromConfig(allocator, &cfg, provider, &runtime_tools, null, noop.observer());
defer agent.deinit();

const StreamSink = struct {
fn onChunk(_: *anyopaque, _: providers.StreamChunk) void {}
};
var stream_ctx: u8 = 0;
agent.stream_callback = StreamSink.onChunk;
agent.stream_ctx = @ptrCast(&stream_ctx);

const response = try agent.turn("call shell");
defer allocator.free(response);

try std.testing.expectEqual(@as(usize, 2), provider_state.calls);
try std.testing.expectEqualStrings("done", response);
}

test "buildProviderMessagesForTurn adds priority hint without mutating history" {
// Regression: priority hints must not be persisted as user text in history/memory/cache.
const allocator = std.testing.allocator;
Expand Down
8 changes: 8 additions & 0 deletions src/providers/anthropic.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,14 @@ test "supportsStreamingImpl returns true" {
try std.testing.expect(prov.supportsStreaming());
}

test "supportsStreamingNativeTools returns false until input_json_delta is parsed" {
var p = AnthropicProvider.init(std.testing.allocator, "key", null);
const prov = p.provider();
try std.testing.expect(prov.supportsNativeTools());
try std.testing.expect(prov.supportsStreaming());
try std.testing.expect(!prov.supportsStreamingNativeTools());
}

test "vtable stream_chat is not null" {
try std.testing.expect(AnthropicProvider.vtable.stream_chat != null);
}
Expand Down
27 changes: 27 additions & 0 deletions src/providers/compatible.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1144,6 +1144,7 @@ pub const OpenAiCompatibleProvider = struct {
.deinit = deinitImpl,
.stream_chat = streamChatImpl,
.supports_streaming = supportsStreamingImpl,
.supports_streaming_native_tools = supportsStreamingNativeToolsImpl,
};

fn buildSingleTurnMessages(
Expand Down Expand Up @@ -1303,6 +1304,11 @@ pub const OpenAiCompatibleProvider = struct {
return !self.disable_streaming and self.api_mode != .responses;
}

fn supportsStreamingNativeToolsImpl(ptr: *anyopaque) bool {
const self: *OpenAiCompatibleProvider = @ptrCast(@alignCast(ptr));
return self.native_tools and supportsStreamingImpl(ptr);
}

fn chatWithSystemImpl(
ptr: *anyopaque,
allocator: std.mem.Allocator,
Expand Down Expand Up @@ -2281,6 +2287,27 @@ test "supportsNativeTools returns true for compatible" {
try std.testing.expect(prov.supportsNativeTools());
}

test "supportsStreamingNativeTools follows native tools and streaming" {
var p = OpenAiCompatibleProvider.init(std.testing.allocator, "test", "https://example.com", "key", .bearer, null);
var prov = p.provider();
try std.testing.expect(prov.supportsNativeTools());
try std.testing.expect(prov.supportsStreaming());
try std.testing.expect(prov.supportsStreamingNativeTools());

p.native_tools = false;
prov = p.provider();
try std.testing.expect(!prov.supportsNativeTools());
try std.testing.expect(prov.supportsStreaming());
try std.testing.expect(!prov.supportsStreamingNativeTools());

p.native_tools = true;
p.disable_streaming = true;
prov = p.provider();
try std.testing.expect(prov.supportsNativeTools());
try std.testing.expect(!prov.supportsStreaming());
try std.testing.expect(!prov.supportsStreamingNativeTools());
}

test "capNonStreamingMaxTokens caps request max_tokens above provider limit" {
const msgs = [_]root.ChatMessage{root.ChatMessage.user("hello")};
const req = root.ChatRequest{ .messages = &msgs, .model = "test-model", .max_tokens = 8000 };
Expand Down
13 changes: 13 additions & 0 deletions src/providers/openai.zig
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ pub const OpenAiProvider = struct {
.deinit = deinitImpl,
.stream_chat = streamChatImpl,
.supports_streaming = supportsStreamingImpl,
.supports_streaming_native_tools = supportsStreamingNativeToolsImpl,
};

fn streamChatImpl(
Expand Down Expand Up @@ -262,6 +263,10 @@ pub const OpenAiProvider = struct {
return true;
}

fn supportsStreamingNativeToolsImpl(_: *anyopaque) bool {
return true;
}

fn chatWithSystemImpl(
ptr: *anyopaque,
allocator: std.mem.Allocator,
Expand Down Expand Up @@ -530,6 +535,14 @@ test "supportsNativeTools returns true" {
try std.testing.expect(prov.supportsNativeTools());
}

test "supportsStreamingNativeTools returns true" {
var p = OpenAiProvider.init(std.testing.allocator, "key", null, null);
const prov = p.provider();
try std.testing.expect(prov.supportsStreaming());
try std.testing.expect(prov.supportsNativeTools());
try std.testing.expect(prov.supportsStreamingNativeTools());
}

test "parseTextResponse multiple choices returns first" {
const body =
\\{"choices":[{"message":{"content":"A"}},{"message":{"content":"B"}}]}
Expand Down
Loading
Loading