Skip to content
Draft
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
2 changes: 1 addition & 1 deletion src/acp/prompt.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2865,7 +2865,7 @@ fn describeToolTitle(registry: tool_dispatch.Registry, arena: Allocator, call: T
.call = call,
});
}
if (tool_dispatch.toolCallPresentation(arena, registry, call)) |presentation| {
if (tool_dispatch.toolCallPresentation(registry, call)) |presentation| {
return std.fmt.allocPrint(arena, "{s}", .{presentation.action_label});
}
return std.fmt.allocPrint(arena, "{s}", .{call.name});
Expand Down
4 changes: 4 additions & 0 deletions src/core/agent/runtime/deps.zig
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const TransportPublicationOutcome = tool_contracts.TransportPublicationOutcome;
pub const LiveToolAuthority = tool_contracts.LiveToolAuthority;

pub const RecoveryCheckpointEffect = struct {
/// The checkpoint borrows caller-owned scratch memory that is freed as
/// soon as this call returns. A sink must serialize the checkpoint or
/// dupe it with its own allocator before returning; it must not retain
/// the passed pointers.
set: *const fn (ctx: *anyopaque, checkpoint: session_codec.RecoveryCheckpoint) anyerror!void,
};

Expand Down
154 changes: 117 additions & 37 deletions src/core/agent/runtime/orchestrator.zig

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion src/core/agent/runtime/parallel_execution.zig
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,12 @@ fn cancelRequested(cancel_flag: ?*std.atomic.Value(bool)) bool {

pub fn parallelHookExecute(ctx: *anyopaque, alloc: Allocator, call: ToolCall, index: usize) !ToolExecutionResult {
const exec_ctx: *ParallelHookExecContext = @ptrCast(@alignCast(ctx));
// Same per-call scratch ownership as the sequential path: call scratch is
// reclaimed when the call returns, survivors are copied to alloc.
var call_arena_state = std.heap.ArenaAllocator.init(std.heap.c_allocator);
defer call_arena_state.deinit();
return exec_ctx.hooks.execute_tool_call(exec_ctx.hooks.ctx, .{
.call_allocator = alloc,
.call_allocator = call_arena_state.allocator(),
.result_allocator = alloc,
.call = call,
.authority = .ordinary,
Expand Down
2 changes: 1 addition & 1 deletion src/core/agent/runtime/tool_batch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ pub fn appendOrdinaryExecutedResult(
memory: types.ToolResultMemory,
execution: ToolExecutionResult,
) !void {
const activity = runtime_tool_presentation.activityKindForCall(arena, tool_registry, tool_call);
const activity = runtime_tool_presentation.activityKindForCall(tool_registry, tool_call);
try appendToolResultContent(
arena,
within_turn_suffix,
Expand Down
10 changes: 4 additions & 6 deletions src/core/agent/runtime/tool_presentation.zig
Original file line number Diff line number Diff line change
Expand Up @@ -689,12 +689,11 @@ pub fn activityKind(registry: tool_dispatch.Registry, tool_name: []const u8) typ
}

pub fn activityKindForCall(
alloc: Allocator,
registry: tool_dispatch.Registry,
call: ToolCall,
) types.ToolActivityKind {
if (tooling_presentation.isProviderSearchAlias(call.name)) return .read;
return tool_dispatch.toolActivityKindForCall(alloc, registry, call);
return tool_dispatch.toolActivityKindForCall(registry, call);
}

fn formatProvisionalProgressLabel(
Expand Down Expand Up @@ -736,7 +735,7 @@ pub noinline fn startToolVisibleLifecycle(
display_target: ?[]const u8,
advertised_dynamic_tool_names: []const []const u8,
) !bool {
const activity_kind = activityKindForCall(arena, hooks.tool_registry, call);
const activity_kind = activityKindForCall(hooks.tool_registry, call);
if (activity_kind == .ask) return false;
const redacted_arguments = try text_utils.maskSecrets(arena, call.arguments_json);
const activity_line = try hooks.describe_tool_action(
Expand Down Expand Up @@ -861,7 +860,7 @@ fn finishDeniedToolStatusInternal(
label,
advertised_dynamic_tool_names,
);
const command_artifact_handle = if (activityKindForCall(arena, hooks.tool_registry, call) == .command)
const command_artifact_handle = if (activityKindForCall(hooks.tool_registry, call) == .command)
try commandArtifactHandle(arena, command_result_json)
else
null;
Expand Down Expand Up @@ -908,7 +907,6 @@ pub fn finishCancelledToolStatus(
advertised_dynamic_tool_names,
);
const command_activity = activityKindForCall(
arena,
hooks.tool_registry,
call,
) == .command;
Expand Down Expand Up @@ -972,7 +970,7 @@ pub fn finishExecutedToolStatus(
advertised_dynamic_tool_names: []const []const u8,
) !void {
if (!status_started) return;
const activity_kind = activityKindForCall(arena, hooks.tool_registry, call);
const activity_kind = activityKindForCall(hooks.tool_registry, call);
const command_decision = if (activity_kind == .command)
try commandOutcomeDecision(arena, result_memory.command_process_presentation)
else
Expand Down
27 changes: 1 addition & 26 deletions src/core/agent/worker_runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3309,32 +3309,7 @@ fn dupeToolResultMemory(
source: ?types.ToolResultMemory,
) !?types.ToolResultMemory {
const memory = source orelse return null;
const output_handle = if (memory.output_handle) |handle|
try alloc.dupe(u8, handle)
else
null;
errdefer if (output_handle) |handle| alloc.free(handle);
const preview = if (memory.preview) |value|
try alloc.dupe(u8, value)
else
null;
errdefer if (preview) |value| alloc.free(value);
const command_output_replay = if (memory.command_output_replay) |replay|
try types.dupeCommandOutputReplay(alloc, replay)
else
null;
errdefer if (command_output_replay) |replay| types.freeCommandOutputReplay(alloc, replay);
return .{
.output_handle = output_handle,
.preview = preview,
.output_bytes = memory.output_bytes,
.stored_output_bytes = memory.stored_output_bytes,
.truncated = memory.truncated,
.model_view_covers_full_file = memory.model_view_covers_full_file,
.command_output_replay = command_output_replay,
.command_process_presentation = memory.command_process_presentation,
.terminal_action_presentation = memory.terminal_action_presentation,
};
return try types.dupeToolResultMemory(alloc, memory);
}

fn freeToolResultMemory(
Expand Down
170 changes: 170 additions & 0 deletions src/core/shared/types.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2391,6 +2391,176 @@ pub fn freeCommandOutputReplay(
}
}

/// Deep-copies every slice-bearing field so the result is owned by `alloc`.
/// The caller owns the returned memory.
pub fn dupeToolResultMemory(
alloc: std.mem.Allocator,
memory: ToolResultMemory,
) !ToolResultMemory {
const output_handle = if (memory.output_handle) |handle|
try alloc.dupe(u8, handle)
else
null;
errdefer if (output_handle) |handle| alloc.free(@constCast(handle));
const preview = if (memory.preview) |value|
try alloc.dupe(u8, value)
else
null;
errdefer if (preview) |value| alloc.free(@constCast(value));
const command_output_replay = if (memory.command_output_replay) |replay|
try dupeCommandOutputReplay(alloc, replay)
else
null;
errdefer if (command_output_replay) |replay| freeCommandOutputReplay(alloc, replay);
const committed_file_presentation = if (memory.committed_file_presentation) |presentation|
try dupeCommittedFilePresentation(alloc, presentation)
else
null;
return .{
.output_handle = output_handle,
.preview = preview,
.output_bytes = memory.output_bytes,
.stored_output_bytes = memory.stored_output_bytes,
.truncated = memory.truncated,
.model_view_covers_full_file = memory.model_view_covers_full_file,
.committed_file_presentation = committed_file_presentation,
.command_output_replay = command_output_replay,
.command_process_presentation = memory.command_process_presentation,
.terminal_action_presentation = memory.terminal_action_presentation,
};
}

/// Deep-copies every slice-bearing field of a provider completion so the
/// result is owned by `alloc`. Arena callers rely on this to move a completion
/// out of a shorter-lived attempt allocator.
pub fn dupeModelCompletion(alloc: std.mem.Allocator, completion: ModelCompletion) !ModelCompletion {
var copy = completion;
copy.content = if (completion.content) |value| try alloc.dupe(u8, value) else null;
errdefer if (copy.content) |value| alloc.free(@constCast(value));
copy.tool_calls = try dupeToolCallSlice(alloc, completion.tool_calls);
errdefer freeToolCallSlice(alloc, @constCast(copy.tool_calls));
copy.generation_id = if (completion.generation_id) |value| try alloc.dupe(u8, value) else null;
errdefer if (copy.generation_id) |value| alloc.free(@constCast(value));
if (completion.billing) |billing| copy.billing.?.model = try alloc.dupe(u8, billing.model);
errdefer if (copy.billing) |billing| alloc.free(@constCast(billing.model));
copy.provider_failure_detail = if (completion.provider_failure_detail) |value| try alloc.dupe(u8, value) else null;
errdefer if (copy.provider_failure_detail) |value| alloc.free(@constCast(value));
copy.provider_state_json = if (completion.provider_state_json) |value| try alloc.dupe(u8, value) else null;
return copy;
}

test "model completion dupe covers every slice-bearing field" {
// Tripwire: adding a field to ModelCompletion requires extending
// dupeModelCompletion (and the attempt-boundary copy-out that relies on
// it) before bumping this count.
comptime std.debug.assert(@typeInfo(ModelCompletion).@"struct".fields.len == 12);
comptime std.debug.assert(@typeInfo(ProviderBilling).@"struct".fields.len == 9);

var source_state = std.heap.ArenaAllocator.init(std.testing.allocator);
const source_alloc = source_state.allocator();
const source = ModelCompletion{
.content = try source_alloc.dupe(u8, "answer"),
.tool_calls = try dupeToolCallSlice(source_alloc, &.{.{
.id = "call_1",
.name = "read_file",
.arguments_json = "{}",
.provisional_id = "tmp_1",
.provider_result = "{\"ok\":true}",
}}),
.generation_id = try source_alloc.dupe(u8, "gen_1"),
.billing = .{
.created_at_ms = 7,
.model = try source_alloc.dupe(u8, "model-x"),
.total_cost = 0.5,
.input_tokens = 1,
.output_tokens = 2,
.cache_read_tokens = 3,
.cache_write_tokens = 4,
.reasoning_tokens = 5,
.billable_web_search_calls = 6,
},
.generation_metadata_invalid = true,
.delivery_ambiguous = true,
.provider_result_identity_failure = .absent,
.provider_failure_cause = .gateway_stream_timeout,
.provider_failure_detail = try source_alloc.dupe(u8, "detail"),
.provider_state_json = try source_alloc.dupe(u8, "[{\"id\":\"rs_1\"}]"),
.finish_reason = .tool_calls,
.usage = .{ .input_tokens = 9 },
};

var owned_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer owned_state.deinit();
const owned = try dupeModelCompletion(owned_state.allocator(), source);
source_state.deinit();

try std.testing.expectEqualStrings("answer", owned.content.?);
try std.testing.expectEqual(@as(usize, 1), owned.tool_calls.len);
try std.testing.expectEqualStrings("call_1", owned.tool_calls[0].id);
try std.testing.expectEqualStrings("read_file", owned.tool_calls[0].name);
try std.testing.expectEqualStrings("{}", owned.tool_calls[0].arguments_json);
try std.testing.expectEqualStrings("tmp_1", owned.tool_calls[0].provisional_id.?);
try std.testing.expectEqualStrings("{\"ok\":true}", owned.tool_calls[0].provider_result.?);
try std.testing.expectEqualStrings("gen_1", owned.generation_id.?);
try std.testing.expectEqualStrings("model-x", owned.billing.?.model);
try std.testing.expectEqual(@as(u64, 4), owned.billing.?.cache_write_tokens);
try std.testing.expect(owned.generation_metadata_invalid);
try std.testing.expect(owned.delivery_ambiguous);
try std.testing.expectEqual(ProviderResultIdentityFailure.absent, owned.provider_result_identity_failure.?);
try std.testing.expectEqual(ProviderFailureCause.gateway_stream_timeout, owned.provider_failure_cause.?);
try std.testing.expectEqualStrings("detail", owned.provider_failure_detail.?);
try std.testing.expectEqualStrings("[{\"id\":\"rs_1\"}]", owned.provider_state_json.?);
try std.testing.expectEqual(ProviderFinishReason.tool_calls, owned.finish_reason.?);
try std.testing.expectEqual(@as(?u64, 9), owned.usage.input_tokens);
}

test "tool result memory dupe covers every slice-bearing field" {
// Tripwire: adding a field to ToolResultMemory requires extending
// dupeToolResultMemory (and the dispatch-boundary copy-out that relies on
// it) before bumping this count.
comptime std.debug.assert(@typeInfo(ToolResultMemory).@"struct".fields.len == 10);

var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const source = ToolResultMemory{
.output_handle = "handle.txt",
.preview = "preview text",
.output_bytes = 42,
.stored_output_bytes = 21,
.truncated = true,
.model_view_covers_full_file = false,
.committed_file_presentation = .{
.path = "src/file.zig",
.kind = .edited,
.lines = &.{},
.additions = 1,
.deletions = 2,
.truncated = false,
},
.command_output_replay = .{ .available = .{
.handle = "replay-handle",
.framed_bytes = 7,
} },
.command_process_presentation = .{ .signal = 9 },
.terminal_action_presentation = .{ .returned = .safety_ceiling },
};

const owned = try dupeToolResultMemory(arena, source);
try std.testing.expectEqualStrings("handle.txt", owned.output_handle.?);
try std.testing.expect(owned.output_handle.?.ptr != source.output_handle.?.ptr);
try std.testing.expectEqualStrings("preview text", owned.preview.?);
try std.testing.expect(owned.preview.?.ptr != source.preview.?.ptr);
try std.testing.expectEqualStrings("src/file.zig", owned.committed_file_presentation.?.path);
try std.testing.expect(owned.committed_file_presentation.?.path.ptr !=
source.committed_file_presentation.?.path.ptr);
try std.testing.expectEqualStrings("replay-handle", owned.command_output_replay.?.available.handle);
try std.testing.expect(owned.command_output_replay.?.available.handle.ptr !=
source.command_output_replay.?.available.handle.ptr);
try std.testing.expectEqual(@as(usize, 42), owned.output_bytes);
try std.testing.expectEqual(source.command_process_presentation, owned.command_process_presentation);
}

pub fn dupePermissionFeedback(
alloc: std.mem.Allocator,
feedback: []const []const u8,
Expand Down
2 changes: 1 addition & 1 deletion src/core/tooling/file_mutation_execution.zig
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ fn fileMutationFailure(
};
}

fn allocatorsEqual(a: Allocator, b: Allocator) bool {
pub fn allocatorsEqual(a: Allocator, b: Allocator) bool {
return a.ptr == b.ptr and a.vtable == b.vtable;
}

Expand Down
8 changes: 4 additions & 4 deletions src/core/tooling/tool_dispatch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -578,12 +578,13 @@ pub fn presentationForArgs(tool: Tool, args: std.json.ObjectMap) CallPresentatio
}

pub fn toolCallPresentation(
alloc: Allocator,
registry: Registry,
call: core_types.ToolCall,
) ?CallPresentation {
const tool = registry.lookup(call.name) orelse return null;
var scratch_state = std.heap.ArenaAllocator.init(alloc);
// Scratch is backed by c_allocator so deinit reclaims it even when the
// caller's allocator is the per-turn arena.
var scratch_state = std.heap.ArenaAllocator.init(std.heap.c_allocator);
defer scratch_state.deinit();
const args = tool_args.parseToolArgsObject(
scratch_state.allocator(),
Expand All @@ -593,11 +594,10 @@ pub fn toolCallPresentation(
}

pub fn toolActivityKindForCall(
alloc: Allocator,
registry: Registry,
call: core_types.ToolCall,
) core_types.ToolActivityKind {
const presentation = toolCallPresentation(alloc, registry, call) orelse
const presentation = toolCallPresentation(registry, call) orelse
return .command;
return presentation.activity_kind;
}
Expand Down
6 changes: 4 additions & 2 deletions src/core/tooling/tool_result_limits.zig
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ pub fn prepareRedactedOutput(
alloc: Allocator,
raw: []const u8,
) error{OutOfMemory}![]u8 {
var scratch_impl = std.heap.ArenaAllocator.init(alloc);
var scratch_impl = std.heap.ArenaAllocator.init(std.heap.c_allocator);
defer scratch_impl.deinit();
const redacted = try redactModelText(scratch_impl.allocator(), raw);
return alloc.dupe(u8, redacted);
Expand All @@ -47,7 +47,9 @@ pub fn prepareModelOutputWithTruncation(
raw: []const u8,
max_bytes: usize,
) error{OutOfMemory}!PreparedModelOutput {
var scratch_impl = std.heap.ArenaAllocator.init(alloc);
// Scratch is backed by c_allocator so deinit reclaims it even when the
// caller's allocator is the per-turn arena.
var scratch_impl = std.heap.ArenaAllocator.init(std.heap.c_allocator);
defer scratch_impl.deinit();
const scratch = scratch_impl.allocator();

Expand Down
Loading