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
14 changes: 7 additions & 7 deletions src/agent_compact.zig
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ const context_tokens = @import("context_tokens.zig");
const main_mod = @import("main.zig");
const agent_mod = @import("agent.zig");
const Agent = agent_mod.Agent;
const prompts = @import("prompts.zig");
const compact_instruction = prompts.compact_instruction;
const goal_flow = @import("goal_flow.zig");
const handoff_note = @import("compact_handoff_note.zig"); // #411: both halves of "what survives a compaction"

const messages_mod = @import("messages.zig");
const textMessage = messages_mod.textMessage;
Expand Down Expand Up @@ -161,7 +160,7 @@ pub fn compact(self: *Agent) anyerror!usize {
}
};

try self.messages.append(try textMessage(compact_arena, "user", compact_instruction));
try self.messages.append(try textMessage(compact_arena, "user", try handoff_note.summaryRequest(compact_arena, self)));
// #174: establish the synthetic summary turn before pruning Responses
// reasoning. An active tool loop's reasoning is newer than the real user
// turn and must remain while that loop is in flight, but it becomes prior-
Expand Down Expand Up @@ -195,7 +194,7 @@ pub fn compact(self: *Agent) anyerror!usize {
}

var fresh = std.json.Array.init(self.arena);
try fresh.append(try textMessage(self.arena, "user", try handoffMessage(self, summary)));
try fresh.append(try textMessage(self.arena, "user", try handoffMessage(self, summary, live_messages.items[0..recent_start])));
// Preserve a valid recent suffix verbatim (up to ~8k estimated tokens),
// including its user boundary and paired tool calls/results.
for (recent_messages) |message| try fresh.append(message);
Expand Down Expand Up @@ -225,13 +224,14 @@ pub fn compact(self: *Agent) anyerror!usize {
/// Without a pin, compaction would summarize the mandate away with nothing
/// left to restate it - childHandoff below restates it verbatim instead of
/// re-deriving it, so it can never drift or compound across compactions.
pub fn handoffMessage(self: *Agent, summary: []const u8) ![]const u8 {
/// `discarded` is the history this summary replaces, read only for the #409 artifact paths #411 re-states out of it.
pub fn handoffMessage(self: *Agent, summary: []const u8, discarded: []const Value) ![]const u8 {
const base = if (self.sub)
(if (self.task_prompt) |tp| try childHandoff(self, tp, summary) else try rootHandoff(self, summary))
else
try rootHandoff(self, summary);
const standing = (try goal_flow.compactionSnapshot(self.arena, self)) orelse return base;
return std.fmt.allocPrint(self.arena, "{s}\n\n{s}", .{ base, standing });
const standing = try goal_flow.compactionSnapshot(self.arena, self);
return handoff_note.handoff(self.arena, self, base, standing, discarded);
}

fn rootHandoff(self: *Agent, summary: []const u8) ![]const u8 {
Expand Down
42 changes: 42 additions & 0 deletions src/agent_compact_summary_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ const std = @import("std");
const Agent = @import("agent.zig").Agent;
const compact = @import("agent_compact.zig");
const repeatedEmptySummaryFailure = compact.repeatedEmptySummaryFailure;
const handoff_note = @import("compact_handoff_note.zig");
const session_transcript = @import("session_transcript.zig");
const compact_instruction = @import("prompts.zig").compact_instruction;

test "repeated empty summaries unlock bounded recovery (#379)" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
Expand Down Expand Up @@ -49,3 +52,42 @@ test "repeated empty summaries unlock bounded recovery (#379)" {
agent.compact_summary_failures = 1;
try std.testing.expect(!repeatedEmptySummaryFailure(&agent, error.EmptySummary));
}

// #411 appended a "what persists" note to the summary REQUEST. #379 classifies
// the RESPONSE to that request, so the two must not interact: the request still
// LEADS with the byte-identical instruction, carries none of the after-the-fact
// ground truth, and two consecutive unusable replies still escalate exactly as
// they did. A note that changed the request's head is the shape of regression
// this guards - it would be invisible in #411's own tests.
test "#411's request note leaves #379's empty-summary escalation exactly as it was" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
session_transcript.resetForTest();
defer session_transcript.resetForTest();

var agent: Agent = undefined;
agent.provider = .{ .id = "codex", .kind = .responses, .auth = .bearer, .url = "", .api_key = "", .model = "gpt-5", .context = 100_000 };
agent.messages = std.json.Array.init(a);
agent.sub = false;
agent.review_mode = false;
agent.snapshots = null;
agent.session_name = "";
agent.strict = false;
agent.sys_normal = "";
agent.sys_strict = "";
agent.tools_responses = "";
agent.last_context_tokens = 85_000;
agent.context_local_tokens = agent.fullRequestEstimateTokens();
agent.compact_summary_failures = 0;

const request = try handoff_note.summaryRequest(a, &agent);
try std.testing.expect(std.mem.startsWith(u8, request, compact_instruction));
try std.testing.expect(std.mem.indexOf(u8, request, "durable state, re-derived") == null);
try std.testing.expect(!repeatedEmptySummaryFailure(&agent, error.EmptySummary));
try std.testing.expect(repeatedEmptySummaryFailure(&agent, error.EmptySummary));
// And a usable summary still ends the streak, which is what stops a healthy
// session accumulating its way into an emergency trim.
agent.compact_summary_failures = 0;
try std.testing.expect(!repeatedEmptySummaryFailure(&agent, error.ApiError));
}
21 changes: 9 additions & 12 deletions src/agent_compact_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -440,19 +440,16 @@ test "a compaction handoff carries the live checklist across the summary (#318)"
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
var agent: Agent = undefined;
agent.arena = a;
agent.task_prompt = null; // this test flips agent.sub to true below (#B3)
agent.sub = false;
agent.review_mode = false;
agent.todos = .empty;
// Root agent, every field handoffMessage reads initialized (incl. #411's
// ledger + session name); this test flips agent.sub to true below (#B3).
var agent = th.subAgent(a, false);
agent.goal = .{ .objective = "ship the epoch fix", .epoch = 2 };
try agent.todos.append(a, .{ .content = "write the helper", .status = "completed", .epoch = 2 });
try agent.todos.append(a, .{ .content = "wire it into compact", .status = "completed", .epoch = 2 });
try agent.todos.append(a, .{ .content = "test it", .status = "pending", .epoch = 2 });
try agent.todos.append(a, .{ .content = "parked by an older goal", .status = "pending", .epoch = 1 });

const handoff = try handoffMessage(&agent, "the model summarized the earlier work");
const handoff = try handoffMessage(&agent, "the model summarized the earlier work", &.{});
// The summary and its framing are unchanged...
try std.testing.expect(std.mem.indexOf(u8, handoff, "the model summarized the earlier work") != null);
try std.testing.expect(std.mem.indexOf(u8, handoff, "Continue assisting the user based on this summary.") != null);
Expand All @@ -476,12 +473,12 @@ test "a compaction handoff carries the live checklist across the summary (#318)"
// A subagent shares the Agent struct but not the goal, so its handoff is
// byte-identical to the pre-#318 text - as is a session with no goal.
agent.sub = true;
const plain = try handoffMessage(&agent, "the model summarized the earlier work");
const plain = try handoffMessage(&agent, "the model summarized the earlier work", &.{});
try std.testing.expect(std.mem.indexOf(u8, plain, "standing state") == null);
try std.testing.expect(std.mem.endsWith(u8, plain, "Continue assisting the user based on this summary."));
agent.sub = false;
agent.goal = null;
try std.testing.expectEqualStrings(plain, try handoffMessage(&agent, "the model summarized the earlier work"));
try std.testing.expectEqualStrings(plain, try handoffMessage(&agent, "the model summarized the earlier work", &.{}));
}

test "an emergency trim re-queues the standing state, never over a user note (#318)" {
Expand Down Expand Up @@ -553,15 +550,15 @@ test "a compacting subagent's handoff restates its task prompt verbatim" {
const a = arena_state.allocator();
var agent = th.subAgent(a, true);
agent.task_prompt = "AUDIT src/foo.zig and report every unguarded json deref";
const handoff = try handoffMessage(&agent, "the model summarized the earlier work");
const handoff = try handoffMessage(&agent, "the model summarized the earlier work", &.{});
try std.testing.expect(std.mem.indexOf(u8, handoff, agent.task_prompt.?) != null and std.mem.indexOf(u8, handoff, "the model summarized the earlier work") != null and std.mem.indexOf(u8, handoff, "still your mandate") != null);
}
test "the root handoff is byte-identical to before the child pin" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const a = arena_state.allocator();
var agent = th.subAgent(a, false);
const handoff = try handoffMessage(&agent, "the model summarized the earlier work");
const handoff = try handoffMessage(&agent, "the model summarized the earlier work", &.{});
try std.testing.expectEqualStrings("Context: the earlier conversation was compacted to save space.\nSummary of the earlier work:\n\nthe model summarized the earlier work\n\nContinue assisting the user based on this summary.", handoff);
}
test "pinChildTask captures the mandate once, never re-pins, and ignores a root agent or an unrecognised head" {
Expand All @@ -588,7 +585,7 @@ test "an oversized task prompt is head-capped in the child handoff" {
var agent = th.subAgent(a, true);
const big = util.repeatBytes("T", 20000);
agent.task_prompt = &big;
const handoff = try handoffMessage(&agent, "summary text");
const handoff = try handoffMessage(&agent, "summary text", &.{});
try std.testing.expect(handoff.len < 12_000 and std.mem.indexOf(u8, handoff, "task prompt truncated for the handoff") != null and std.mem.indexOf(u8, handoff, big[0..100]) != null and std.mem.indexOf(u8, handoff, &big) == null);
}
test "emergencyCutIndex finds no cut in a subagent-shaped history, so the pinned mandate survives an emergency trim" {
Expand Down
5 changes: 5 additions & 0 deletions src/agent_compact_test_support.zig
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ pub fn subAgent(a: std.mem.Allocator, sub: bool) Agent {
agent.todos = .empty;
agent.goal = null;
agent.task_prompt = null;
// #411: the durable-state note reads the /rewind ledger and the session
// name on every handoff, including the null paths, so both have to be real
// here or an `undefined` pointer gets dereferenced instead of skipped.
agent.snapshots = null;
agent.session_name = "";
// compactPrelude reports the token figure compact() prints, so the estimate
// path has to be reachable: system prompt, tool json, provider context and
// the two meter anchors it reads.
Expand Down
Loading