Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
02daacd
feat(engine): sayText — say() for a line a sink already rendered (#422)
justrach Aug 6, 2026
8500382
feat(engine): the tool-execution cluster gets a vocabulary and both s…
justrach Aug 6, 2026
53ffb49
feat(engine): agent_tools emits typed events — the ansi import is gon…
justrach Aug 6, 2026
ad622e8
fix(engine): a frontendless agent's wire sink reserves no sequence id…
justrach Aug 6, 2026
ea998d3
feat(context): oversized tool outputs spill to a session artifact (#409)
theprimeradiant Aug 6, 2026
6bb92ce
feat(goal): skip a re-verify the workspace cannot have changed (#412)
justrach Aug 6, 2026
6e9f2f9
test(context): end-to-end proof that the spill loop closes (#409)
theprimeradiant Aug 6, 2026
31824d9
docs: the cap keeps the bytes now, and says where (#409)
theprimeradiant Aug 6, 2026
df50f69
feat(locks): key owner liveness on pid + process start identity (#413)
justrach Aug 6, 2026
3d8d5a2
docs(context): say why the artifact sweep reclaims late, not at the r…
theprimeradiant Aug 6, 2026
5d862f6
fix(context): overflow classifier gets non-overflow guards + two beha…
theprimeradiant Aug 6, 2026
598a801
feat(prompt): capability-gated system-prompt assembly + the session t…
theprimeradiant Aug 6, 2026
87630b0
merge: feat/422-engine-slice1c into integration/prime-batch
justrach Aug 6, 2026
d5c0f89
merge: feat/prime-412-goalfp into integration/prime-batch
justrach Aug 6, 2026
f331b51
merge: feat/prime-413-locks into integration/prime-batch
justrach Aug 6, 2026
08196f5
merge: feat/prime-409-spill into integration/prime-batch
justrach Aug 6, 2026
466bb1d
merge: feat/prime-414-overflow into integration/prime-batch
justrach Aug 6, 2026
e526808
merge: feat/prime-410-prompt into integration/prime-batch
justrach Aug 6, 2026
fb3db34
fix(test): #409 spill needle is a string, not a bare 'N' the tmpdir c…
justrach Aug 6, 2026
ec86f13
fix(test): #409 spill needle is a string, not a bare 'N' the tmpdir c…
justrach Aug 6, 2026
2428753
fix(goal): .graff/ bookkeeping never counts as workspace progress (#412)
justrach Aug 6, 2026
ee28d8c
fix(test): the spill marker's path is asserted portably — windows pat…
justrach Aug 6, 2026
5e53018
merge: post-integration #409 fixes (windows-portable spill marker ass…
theprimeradiant Aug 6, 2026
49084d0
merge: post-integration #412 fixes (.graff/ excluded from the untrack…
theprimeradiant Aug 6, 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
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
71 changes: 70 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,76 @@ 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)

- The system prompt is now assembled from capability-gated segments (#421):
an instruction ships only when its capability is actually present, read
from the same predicates dispatch uses to refuse a hallucinated call, so
the prompt can never disagree with the tool catalog. An embedder session
(`--no-local-tools`) drops 28.5% of the base prompt; the floor config
drops 43.2%. At full capability the compose returns the comptime constant
itself — zero allocation, byte-identical to what shipped before. Two
standing doctrine lines joined the always-on intro: never invent a tool or
wrapper API, and run the target project through its OWN environment — a
failure there is the relevant result.
- A durable root session's prompt now names its own transcript (#410): the
path to `.graff/sessions/<name>.session.json`, described truthfully — one
JSON object, lags the live turn, rewritten in place by compaction (a
resume artifact, not an append-only archive) — so the model can consult
it without burning turns on wrong assumptions. Suppressed when local
tools are gone (an unreadable path is pure token waste).
- Context-overflow classification got a guard list and two behavioral
detectors (#414). Non-overflow patterns are now checked FIRST, so a
throttle whose wording collides with an overflow phrase — Bedrock's
`ThrottlingException: Too many tokens` is the canonical one — keeps riding
the Retry-After ladder instead of triggering a compaction that cannot help.
The overflow table itself grew from six substrings to the phrasings twelve
more providers actually send (Bedrock, Gemini, Copilot, xAI, Groq,
llama.cpp, Kimi, Mistral, Ollama, MiniMax, z.ai, Anthropic 413), and now
matches case-insensitively. Two shapes that never send an error at all are
caught from the reported usage instead: an HTTP 200 with an empty
completion whose input is at or over the window (z.ai's silent overflow),
and `finish_reason: length` with zero output while the input fills the
window (a provider truncating the input to fit, seen on MiMo) — the latter
is now named rather than shipped as an inexplicably short answer.
- 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.
- Cross-process locks stopped trusting a bare pid (#413): an owner record now
carries the holder's process START identity next to its pid (`/proc/<pid>/stat`
field 22 on Linux, `proc_pidinfo` on macOS, `GetProcessTimes` on Windows), so
liveness is "the pid is alive AND it is still the same process". A recycled
pid can no longer look like a live owner forever, and a crashed holder's lock
is reclaimed because its identity provably mismatches rather than because a
timeout guessed. A record written by an older graff carries no identity and
keeps the pid-only contract exactly as before, so an in-flight lock is never
bricked, and an identity that cannot be read fails safe: held, never stolen.
The #320 worktree lease gets the producer it was missing, and a session save
on a filesystem with no working locks (#289) now coordinates through an owner
record instead of racing unguarded.
- A no-progress `eval` is no longer paid for (#412). A goal/loop run
re-verifies on every continuation, so a model that has edited nothing since
the last RED re-ran the whole `--eval` command — plus a `--judge` model call,
plus 1500 bytes of output tail — to re-derive a verdict that could not have
changed. graff now fingerprints the verification before running it (the eval
command text, `git status --porcelain -z -uall`, `git diff --binary HEAD`,
and the contents of every untracked file, since the first two are blind to an
untracked edit); identical to the tree the last verification failed on means
the verifier is not run at all. The attempt still counts, so a stuck loop
converges on its iteration cap instead of spinning for free, and the model is
steered at the real blocker: the workspace has not changed, edit something
first. Fail-open throughout — no repo, no git, a timed-out or truncated
probe, an unreadable file all read as "changed" and the verifier runs.

## 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
32 changes: 22 additions & 10 deletions examples/prepare_graff_tournament.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,34 @@ def write_json(path: Path, value: object) -> None:


def extract_root_prompt(source: Path) -> str:
# justrach/codegraff#421 split the root prompt into capability-scoped
# segments, so "everything after `pub const main_system_prompt =`" no longer
# names one literal. The markers delimit the whole segment region instead.
# Lines WITHIN one segment join with a newline; segments concatenate with
# nothing, which is how prompts.zig composes them (several boundaries fall
# mid-sentence). Joining everything with "\n" would insert a blank line at
# each boundary and the seed genome would not be the shipped prompt.
lines = source.read_text(encoding="utf-8").splitlines()
collecting = False
result: list[str] = []
segments: list[list[str]] = []
for line in lines:
if line.startswith("pub const main_system_prompt ="):
if line.startswith("// ── ROOT PROMPT BEGIN"):
collecting = True
continue
if collecting and line == ";":
if collecting and line.startswith("// ── ROOT PROMPT END"):
break
if collecting:
marker = line.find("\\\\")
if marker >= 0:
result.append(line[marker + 2 :])
if not collecting:
continue
if line.startswith("pub const "):
segments.append([])
continue
marker = line.find("\\\\")
if marker >= 0 and segments:
segments[-1].append(line[marker + 2 :])
result = "".join("\n".join(seg) for seg in segments)
if not result:
raise ValueError("could not extract main_system_prompt")
return "\n".join(result).strip() + "\n"
raise ValueError("could not extract the root prompt segments")
return result.strip() + "\n"


def pin(path: Path) -> dict[str, str]:
Expand Down Expand Up @@ -78,7 +90,7 @@ def main() -> None:
raise FileNotFoundError(path)

parent = output / "parent.md"
parent.write_text(extract_root_prompt(repo / "src" / "prompts.zig"), encoding="utf-8")
parent.write_text(extract_root_prompt(repo / "src" / "prompt_text.zig"), encoding="utf-8")
parent.chmod(0o600)
primary = output / "primary.json"
holdout = output / "fresh-holdout.json"
Expand Down
103 changes: 95 additions & 8 deletions scripts/test-pty-overflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@
B. error.code = rate_limit_exceeded with a non-overflow message. graff must NOT
mistake it for an overflow: the meter must not pin. Proves detection is precise.

#414 adds three more, covering the classifier's guard list and its two behavioral
detectors — the shapes where the provider never returns an error at all:

C. Bedrock's "ThrottlingException: Too many tokens, please wait before trying
again." — wording that collides with the generic "too many tokens" overflow
fallback, but is a 429. It must ride the retry ladder; the meter must not pin.
D. HTTP 200 with an EMPTY completion whose usage reports input at the window
(the z.ai silent overflow). graff must classify it as overflow anyway.
E. HTTP 200 with finish_reason=length and ZERO output at the window (MiMo
truncating our input to fit). graff must name it, not ship a silent short answer.

Requires 127.0.0.1:1234 to be free (the lmstudio provider URL is fixed); skips if a
real LM Studio (or anything) already holds it.
"""
Expand All @@ -37,12 +48,23 @@
METER_RE = re.compile(r"(\d+)k/(\d+)k ctx \((\d+)% · compact@(\d+)k\)")
PINNED_RE = re.compile(r"(\d+)k/(\d+)k ctx \(100% · compact@\d+k\)")

# lmstudio/lmstudio is a CATALOGUED model (pricing.zig context_overlay), so its
# window is fixed at 200k and GRAFF_CONTEXT deliberately cannot shrink it (#203).
# The #414 behavioral fixtures report usage against this number; scenario D
# asserts the meter still reads it, so a catalog change fails loudly here.
LMSTUDIO_WINDOW = 200_000


class OpenAiErrorMock:
"""Serves one fixed OpenAI-style error envelope for every /v1/chat/completions."""
"""Serves one fixed OpenAI-style body for every /v1/chat/completions.

`error_obj` is wrapped as {"error": ...}; pass a `raw` body instead to serve a
successful (HTTP 200) completion, which is how the #414 behavioral shapes are
reproduced — they never send an error to keyword-match.
"""

def __init__(self, error_obj: dict) -> None:
self.body = json.dumps({"error": error_obj}).encode()
def __init__(self, error_obj: dict | None = None, raw: dict | None = None) -> None:
self.body = json.dumps(raw if raw is not None else {"error": error_obj}).encode()
self.hits = 0
parent = self

Expand Down Expand Up @@ -75,9 +97,14 @@ def stop(self) -> None:
self.httpd.server_close()


def _run(error_obj: dict, tmp: str):
"""Run one turn against a mock returning error_obj; return (rendered_text, hits)."""
mock = OpenAiErrorMock(error_obj)
def _run(error_obj: dict, tmp: str, *, raw: dict | None = None, wait_for: str = "api error:"):
"""Run one turn against a mock; return (rendered_text, hits).

`wait_for` is the literal that marks the turn as finished — an error turn ends
with "api error:", but a #414 behavioral-overflow turn ends with a normal
(empty) completion and is only visible through its own notice.
"""
mock = OpenAiErrorMock(error_obj, raw=raw)
mock.start()
try:
env = {
Expand All @@ -102,8 +129,7 @@ def _run(error_obj: dict, tmp: str):
session.wait_for_literal("] ›")
cursor = len(session.raw)
session.send_line("hello")
# The turn ends with an api error either way; wait for it, then settle.
session.wait_for_literal("api error:", start=cursor)
session.wait_for_literal(wait_for, start=cursor)
session.pump_for(1.5)
rendered = terminal_text(bytes(session.raw[cursor:]))

Expand Down Expand Up @@ -181,6 +207,67 @@ def main() -> None:
)
print("ok non-overflow error did not pin the meter (detection is precise)")

# Scenario C (#414 guard): Bedrock's throttle wording collides head-on with
# the generic "too many tokens" overflow fallback. It is a 429: it must ride
# the retry ladder, never trigger a compaction.
rendered, _ = _run(
{
"message": "ThrottlingException: Too many tokens, please wait before trying again.",
"type": "throttling_error",
},
tmp,
)
if "Too many tokens" not in rendered:
raise AssertionError(f"C: throttle error was not surfaced:\n{rendered}")
stray = PINNED_RE.search(rendered)
if stray:
raise AssertionError(
"C: a Bedrock THROTTLE was classified as context overflow — the "
f"non-overflow guard list is not being consulted first ({stray.group(0)!r}):\n{rendered}"
)
print("ok bedrock 'Too many tokens' throttle stayed on the retry path (#414 guard)")

# Scenario D (#414): z.ai's silent overflow. HTTP 200, an EMPTY completion,
# and usage that says the input already filled the window. Nothing in the
# body is an error, so only the behavioral detector can catch it.
rendered, hits = _run(
{},
tmp,
raw={
"choices": [{"index": 0, "message": {"role": "assistant", "content": ""}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": LMSTUDIO_WINDOW, "completion_tokens": 0, "total_tokens": LMSTUDIO_WINDOW},
},
wait_for="silent_overflow",
)
if hits < 1:
raise AssertionError("D: graff never reached the backend")
if "silent_overflow" not in rendered:
raise AssertionError(
f"D: an HTTP 200 with no answer and over-window usage was accepted silently:\n{rendered}"
)
pinned = PINNED_RE.search(rendered)
if not pinned:
raise AssertionError(f"D: silent overflow did not pin the meter to the window:\n{rendered}")
if pinned.group(2) != str(LMSTUDIO_WINDOW // 1000):
raise AssertionError(f"D: lmstudio's catalogued window moved; update LMSTUDIO_WINDOW ({pinned.group(0)!r})")
print("ok silent 200 (empty completion, usage at the window) classified as overflow (#414)")

# Scenario E (#414): MiMo truncates an oversized input to fit the window,
# then reports finish_reason=length with zero output. The reply is not a
# real answer and must be named as such, not shipped as a short one.
rendered, _ = _run(
{},
tmp,
raw={
"choices": [{"index": 0, "message": {"role": "assistant", "content": ""}, "finish_reason": "length"}],
"usage": {"prompt_tokens": LMSTUDIO_WINDOW, "completion_tokens": 0, "total_tokens": LMSTUDIO_WINDOW},
},
wait_for="upstream_truncation",
)
if "upstream_truncation" not in rendered:
raise AssertionError(f"E: upstream truncation surfaced as a silent short answer:\n{rendered}")
print("ok finish_reason=length with zero output at the wall reported as truncation (#414)")


if __name__ == "__main__":
main()
Loading