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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ jobs:
- name: Embedder mode resumable serve streams (#330)
run: python3 scripts/test-serve-resume.py zig-out/bin/graff

- name: Over-cap tool output spills to a session artifact (#409)
run: python3 scripts/test-spill-artifact.py zig-out/bin/graff

- name: Live JSON stream contains no raw stdout lines
run: python3 scripts/test-json-live.py zig-out/bin/graff

Expand Down
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,21 @@ The release workflow uses a tag's section here as its release notes (a
hand-written `docs/releases/<tag>.md` wins if present), so keeping this file
current is part of cutting a release.

## v0.0.240 (unreleased)
## v0.0.241 (unreleased)

- An oversized tool output is now spilled, not destroyed (#409). The
per-result cap (#193/#201) used to delete the elided bytes, leaving the
model to re-run the tool and guess a better slice; when the session is
durable the full output is now written to
`.graff/sessions/<session>/artifacts/tool-<n>.txt` first, and the marker in
the transcript cites the absolute path and the byte count, so the next turn
can read or grep exactly what it needs. Bounded by a 64 MiB per-session
budget (whole artifacts only, so a marker can never overstate what is on
disk), and reclaimed with the session: an artifact dir whose
`<session>.session.json` is gone is swept at the next spill. Subagents,
whose history is never persisted, keep the plain truncation.

## v0.0.240 (2026-08-06)

- The REPL/engine separation began (#422): agent output now flows through a
typed event vocabulary and a strict sink boundary (`engine_events.zig` /
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1067,7 +1067,13 @@ the data plane for task-aware recipe comparison; they do not silently switch
models or effort levels.

Long tool results are stored exactly under `.graff/tool-results/`; model history
receives a short preview and an inspectable file pointer instead. Responses
receives a short preview and an inspectable file pointer instead. A result that
is still over the per-model result cap at send time is spilled the same way
rather than truncated away: the full bytes go to
`.graff/sessions/<session>/artifacts/`, and the note left in the transcript
carries that absolute path and the byte count, so the next turn can read or grep
the slice it needs. Artifacts are bounded per session and are reclaimed once the
session file they belong to is gone. Responses
requests are explicitly capped at 16k output tokens (4k for compaction and 64
for titles), while compaction carries the latest clean ~8k-token user-turn
suffix forward verbatim. A shared atomic run budget allows at most four model
Expand Down
200 changes: 200 additions & 0 deletions scripts/test-spill-artifact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""End-to-end proof that an over-cap tool output is spilled, not destroyed (#409).

The per-output cap (#193/#196) used to delete the elided bytes; the model's only
recovery was to re-run the tool. #409 writes the FULL output to
`.graff/sessions/<session>/artifacts/tool-<n>.txt` first and makes the marker
cite that path, so the next turn can read or grep exactly the slice it needs.

The loop is closed here with the repo's scripted-model recipe
(scripts/eval/mock_model.py on the fixed lmstudio port), against a real graff:

1. a session file is seeded with an oversized tool output carrying a needle
PAST the cap, and graff is started with `--resume` on it;
2. turn 1's request is the harness's own wire history — it must carry the
marker (absolute path + byte count) and NOT the needle;
3. the artifact on disk must hold the original bytes, byte for byte;
4. the mock answers with a `bash` call against THE PATH THE MARKER CITED, and
turn 2's request must carry the needle back — the model recovered the
elided content without re-running the tool.

No network beyond loopback, no provider credentials, no model.

python3 scripts/test-spill-artifact.py [zig-out/bin/graff]
"""

from __future__ import annotations

import json
import os
import pathlib
import re
import shlex
import subprocess
import sys
import tempfile
import time

REPO = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO / "scripts" / "eval"))
from mock_model import ScriptedModel # noqa: E402

SESSION = "spill-e2e"
NEEDLE = "GRAFF-SPILL-NEEDLE-409"
# The cap is window-proportional (Provider.perOutputCap = context/2 bytes), and
# GRAFF_CONTEXT declares the window for an unknown/local model. 40k tokens ->
# a 20_000-byte cap, with auto-compaction (80% = 32k tokens) far out of reach of
# the ~7.5k tokens this history weighs.
CONTEXT_TOKENS = 40_000
OUTPUT_BYTES = 30_000
# "…the FULL <n> bytes are at <path>; read or grep…" — src/tool_spill.zig
MARKER_RE = re.compile(r"the FULL (\d+) bytes are at (\S+?); read or grep")


class SpillModel(ScriptedModel):
"""Turn 1: read back the artifact the marker cited. Turn 2: stop."""

def __init__(self) -> None:
super().__init__([])
self.cited: tuple[int, str] | None = None

def next_reply(self, body: dict) -> dict:
super().next_reply(body) # records the request; the empty script never answers
if len(self.requests) == 1:
found = MARKER_RE.search(json.dumps(body))
if not found:
return {"text": "no marker in the history"}
self.cited = (int(found.group(1)), found.group(2))
return {"tool": "bash", "arguments": {
"command": f"tail -c 120 {shlex.quote(found.group(2))}",
}}
return {"text": "recovered the tail from the artifact"}


def seed_session(workspace: pathlib.Path, output: str) -> None:
"""A saved conversation whose last tool output is over the per-output cap."""
sessions = workspace / ".graff" / "sessions"
sessions.mkdir(parents=True, exist_ok=True)
messages = [
{"role": "user", "content": "dump the build log"},
{"role": "assistant", "content": "", "tool_calls": [{
"id": "call_seed", "type": "function",
"function": {"name": "bash", "arguments": json.dumps({"command": "cat build.log"})},
}]},
{"role": "tool", "tool_call_id": "call_seed", "content": output},
]
(sessions / f"{SESSION}.session.json").write_text(json.dumps({
"provider": "lmstudio", "model": "spill-mock-model", "strict": False,
"ultracode_mode": False, "goal": None, "todos": [],
"title": "spill artifact e2e", "updated_ms": 0, "messages": messages,
}), encoding="utf-8")
harness = workspace / ".harness"
harness.mkdir(parents=True, exist_ok=True)
# The AI titler would otherwise fire an extra quiet turn on the first prompt.
(harness / "settings.json").write_text('{"ai_title": false}', encoding="utf-8")


def run(graff: str, workspace: pathlib.Path, model: SpillModel) -> tuple[str, str, int | None]:
env = {k: v for k, v in os.environ.items() if not k.endswith("_API_KEY")}
env.update({
"HOME": str(workspace),
"LMSTUDIO_API_KEY": "local",
"GRAFF_CONTEXT": str(CONTEXT_TOKENS),
"GRAFF_NO_TELEMETRY": "1",
"GRAFF_FLEET": "off",
"GRAFF_NO_SMOLIFY": "1",
"GRAFF_LEARN_AUTO": "0",
"GRAFF_BEHAVIOR_UPLOAD": "0",
"GRAFF_NO_BROWSER": "1",
"NO_COLOR": "1",
})
argv = [graff, "--json", "--yolo", "--no-telemetry",
"--model", "lmstudio", "--resume", SESSION]
try:
done = subprocess.run(
argv, cwd=workspace, env=env, text=True, capture_output=True,
input=json.dumps({"type": "user", "text": "what did the build log end with?"}) + "\n",
timeout=90,
)
return done.stdout, done.stderr, done.returncode
except subprocess.TimeoutExpired as exc:
out = exc.stdout if isinstance(exc.stdout, str) else (exc.stdout or b"").decode("utf-8", "ignore")
err = exc.stderr if isinstance(exc.stderr, str) else (exc.stderr or b"").decode("utf-8", "ignore")
return out, err, None


def main() -> None:
graff = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else str(REPO / "zig-out" / "bin" / "graff"))
if not os.access(graff, os.X_OK):
sys.exit(f"test-spill-artifact: not an executable: {graff}")

# A padded output whose needle sits well past the cap, so nothing but the
# artifact can still produce it.
output = ("build step ok\n" * 3000)[:OUTPUT_BYTES - len(NEEDLE) - 1] + NEEDLE + "\n"
assert len(output) == OUTPUT_BYTES, len(output)

failures: list[str] = []
spilled: str | None = None
model = SpillModel()
model.start(1234)
try:
with tempfile.TemporaryDirectory(prefix="graff-spill-") as tmp:
workspace = pathlib.Path(tmp)
seed_session(workspace, output)
stdout, stderr, code = run(graff, workspace, model)
# Read the artifact while the workspace still exists.
if model.cited is not None:
try:
spilled = pathlib.Path(model.cited[1]).read_text(encoding="utf-8")
except OSError as exc:
failures.append(f"the cited artifact is not readable: {exc}")
finally:
model.stop()
time.sleep(0.05) # the port is fixed; let the socket clear

if code != 0:
failures.append(f"graff exited {code}\n{stderr[-2000:]}")
if not model.requests:
failures.append("the harness never called the model")
report(failures, stdout, stderr)

first = json.dumps(model.requests[0])
# (b) the capped message carries an actionable marker, and only the marker.
if model.cited is None:
failures.append("request[0] carried no #409 marker (path + byte count)")
else:
cited_bytes, cited_path = model.cited
if cited_bytes != OUTPUT_BYTES:
failures.append(f"the marker claimed {cited_bytes} bytes, wanted {OUTPUT_BYTES}")
if not cited_path.startswith("/"):
failures.append(f"the marker cited a relative path: {cited_path}")
expected_tail = f"/.graff/sessions/{SESSION}/artifacts/tool-0.txt"
if not cited_path.endswith(expected_tail):
failures.append(f"the artifact is not under this session: {cited_path}")
# (a) the artifact holds the ORIGINAL bytes.
if spilled is not None and spilled != output:
failures.append(f"the artifact holds {len(spilled)} bytes, wanted the original {OUTPUT_BYTES}")
if NEEDLE in first:
failures.append("request[0] still carried the elided bytes; the cap did not apply")
if len(model.requests) < 2:
failures.append("the harness never sent a second request, so the read-back never happened")
# (c) the follow-up read of the cited path brought the elided content back.
elif NEEDLE not in json.dumps(model.requests[1]):
failures.append("request[1] did not carry the artifact tail back; the loop does not close")

report(failures, stdout, stderr)


def report(failures: list[str], stdout: str, stderr: str) -> None:
if failures:
print("test-spill-artifact: FAIL")
for failure in failures:
print(f" - {failure}")
print(f"--- graff stdout (tail) ---\n{stdout[-2000:]}")
print(f"--- graff stderr (tail) ---\n{stderr[-2000:]}")
sys.exit(1)
print("test-spill-artifact: ok — over-cap output spilled, cited, and read back (#409)")


if __name__ == "__main__":
main()
70 changes: 18 additions & 52 deletions src/agent_compact.zig
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ const goal_flow = @import("goal_flow.zig");
const messages_mod = @import("messages.zig");
const textMessage = messages_mod.textMessage;

// #409: the per-output cap's truncation primitives, plus the artifact spill that
// now runs inside them. Moved out of this file, which sits at the 600-line cap.
const tool_spill = @import("tool_spill.zig");
const isToolOutputMsg = tool_spill.isToolOutputMsg;
const truncateToolOutput = tool_spill.truncateToolOutput;

const title_mod = @import("title.zig");
const assistantText = title_mod.assistantText;

Expand Down Expand Up @@ -366,56 +372,6 @@ pub fn emergencyCutIndex(items: []const Value) ?usize {
return null;
}

/// True if `m` is a tool-output message whose payload can be truncated to
/// reclaim context: responses `function_call_output`, openai `role:"tool"`, or an
/// anthropic user message carrying `tool_result` blocks (#163).
fn isToolOutputMsg(m: Value) bool {
if (m != .object) return false;
if (m.object.get("type")) |t| if (t == .string and std.mem.eql(u8, t.string, "function_call_output")) return true;
if (m.object.get("role")) |r| if (r == .string) {
if (std.mem.eql(u8, r.string, "tool")) return true;
if (std.mem.eql(u8, r.string, "user")) if (m.object.get("content")) |c| if (c == .array)
for (c.array.items) |blk| {
if (blk == .object) if (blk.object.get("type")) |bt|
if (bt == .string and std.mem.eql(u8, bt.string, "tool_result")) return true;
};
};
return false;
}

fn truncateStrField(arena: Allocator, o: *std.json.ObjectMap, key: []const u8, cap: usize, note: []const u8) usize {
const v = o.get(key) orelse return 0;
if (v != .string or v.string.len <= cap) return 0;
const orig = v.string.len;
// Keep the prefix short enough that prefix + '\n' + note <= cap, so the marker
// never grows an output that was only barely over the cap.
const stub = std.fmt.allocPrint(arena, "{s}\n{s}", .{ utf8Prefix(v.string, cap -| (note.len + 1)), note }) catch return 0;
o.put(arena, key, .{ .string = stub }) catch return 0;
return orig -| stub.len;
}

/// Truncate an over-large tool-output payload in `m` in place to ~`cap` bytes,
/// preserving the message and its call/output pairing. Returns bytes reclaimed.
fn truncateToolOutput(arena: Allocator, m: *Value, cap: usize, note: []const u8) usize {
if (m.* != .object) return 0;
if (m.object.get("type")) |t| if (t == .string and std.mem.eql(u8, t.string, "function_call_output"))
return truncateStrField(arena, &m.object, "output", cap, note);
if (m.object.get("role")) |r| if (r == .string) {
if (std.mem.eql(u8, r.string, "tool")) return truncateStrField(arena, &m.object, "content", cap, note);
if (std.mem.eql(u8, r.string, "user")) if (m.object.get("content")) |c| if (c == .array) {
var saved: usize = 0;
for (m.object.get("content").?.array.items) |*blk| {
if (blk.* != .object) continue;
const bt = blk.object.get("type") orelse continue;
if (bt == .string and std.mem.eql(u8, bt.string, "tool_result"))
saved += truncateStrField(arena, &blk.object, "content", cap, note);
}
return saved;
};
};
return 0;
}

/// Re-pair the meter after removing locally measurable context. The server-only
/// delta remains intact, while the current local component reflects the trim.
fn accountForReclaimedTokens(self: *Agent, reclaimed_tokens: u64) void {
Expand Down Expand Up @@ -447,7 +403,7 @@ pub fn trimOldestToolOutputsAlloc(self: *Agent, arena: Allocator) usize {
if (!isToolOutputMsg(m.*)) continue;
seen += 1;
if (seen > total - keep_recent) break; // keep the most recent verbatim
reclaimed += truncateToolOutput(arena, m, stub_cap, "[old tool output truncated to recover context (#163)]");
reclaimed += truncateToolOutput(arena, m, stub_cap, .{ .fallback = "[old tool output truncated to recover context (#163)]" });
}
accountForReclaimedContext(self, reclaimed);
return reclaimed;
Expand All @@ -467,12 +423,22 @@ pub fn trimOldestToolOutputs(self: *Agent) usize {
/// the most-recent outputs verbatim. Cap is window-proportional (Provider.perOutputCap)
/// so large-context models keep full results untouched. Preserves every call/output
/// pairing (shrinks strings, never drops a message). Returns bytes reclaimed.
///
/// #409: the elided bytes are no longer destroyed. When this agent has a durable
/// session, each oversized output is written to that session's artifact dir
/// first and the marker cites the absolute path and the full byte count, so the
/// model can read or grep the slice it needs instead of re-running the tool. A
/// subagent (no persisted history) keeps the plain truncation below.
pub fn capOversizedToolOutputs(self: *Agent, cap: usize) usize {
if (cap == 0) return 0;
const note: tool_spill.Note = .{
.fallback = "[tool output truncated: over this model's per-result cap — read/fetch a smaller range (#193)]",
.session = tool_spill.sessionFor(self.sub, self.session_name),
};
var reclaimed: usize = 0;
for (self.messages.items) |*m| {
if (isToolOutputMsg(m.*))
reclaimed += truncateToolOutput(self.messageMutationAlloc(), m, cap, "[tool output truncated: over this model's per-result cap — read/fetch a smaller range (#193)]");
reclaimed += truncateToolOutput(self.messageMutationAlloc(), m, cap, note);
}
// These outputs are appended after the prior response's usage was recorded,
// so reclaimed bytes were never part of that authoritative server reading.
Expand Down
4 changes: 2 additions & 2 deletions src/agent_compact_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ test "capOversizedToolOutputs (#193): bounds an oversized output in every wire f
agent.last_context_tokens = 200_000;
agent.provider = .{ .id = "codex", .kind = .responses, .auth = .bearer, .url = "", .api_key = "", .model = "gpt-5", .context = 270_000 };
agent.sub = false;
agent.session_name = ""; // #409: no durable session here, so the cap truncates without spilling
agent.strict = false;
agent.sys_normal = "";
agent.sys_strict = "";
Expand All @@ -358,8 +359,7 @@ test "capOversizedToolOutputs (#193): bounds an oversized output in every wire f
// within-cap output and the non-tool message are untouched
try std.testing.expectEqualStrings("ok", agent.messages.items[3].object.get("output").?.string);
try std.testing.expectEqualStrings("hello", agent.messages.items[4].object.get("content").?.string);
// cap == 0 disables the cap entirely (unknown window)
try std.testing.expectEqual(@as(usize, 0), capOversizedToolOutputs(&agent, 0));
try std.testing.expectEqual(@as(usize, 0), capOversizedToolOutputs(&agent, 0)); // cap == 0 disables the cap entirely (unknown window)
}

test "cleanUserTurn: plain user text yes; assistant/tool_result no" {
Expand Down
Loading