lcc

Tool-call compaction (lcc compact --mode tool-calls)

The message-level sibling of block compaction. Block mode (--mode blocks, the default) scores text blocks inside one document; this mode scores the tool calls of an agent session: it pairs every tool_use with its tool_result by id and drops the pairs a semantic judge says the session no longer needs.

Nothing is summarized. User and assistant text is never scored, never trimmed and never rewritten; a kept result comes back byte for byte; a dropped pair disappears whole. This is the mode a Claude Code hook drives, and the one to compare against summarization-based compaction.

lcc compact transcript.json -q "finish the parser fix" --mode tool-calls \
  -o compacted.json -r report.json
lcc explain report.json          # why every call was kept, trimmed or dropped

1. Input and output

Input is a JSON transcript: either a bare messages array or {"messages": [...]}.

{
  "messages": [
    {"role": "user", "text": "Fix the failing parser test. Never edit src/generated."},
    {"role": "assistant", "text": "Running the suite.",
     "toolUses": [{"tool_use_id": "toolu_1", "tool": "Bash", "input": {"command": "pytest -q"}}]},
    {"role": "user", "toolResults": [{"tool_use_id": "toolu_1", "text": "1 failed, 24 passed"}]}
  ]
}

Snake_case equivalents (tool_calls, tool_results) are accepted too, and a call id may be tool_use_id or id. Output is the same shape, in the same order, with the decided pairs removed or trimmed — so a caller can map it back onto its own session. Messages that lose all of their content are dropped; results stay in the message they arrived in.

Malformed input fails loudly (exit 2): a decision taken over a half-parsed transcript is not auditable. A call without a result yet is left alone — there is nothing to judge.

2. Decisions

For every non-pinned pair the judge answers two typed noul questions: does the call still matter (knowing it happened, with its input), and does its result still need to be there verbatim (its contents are needed and re-running would not do).

Probabilities Decision Effect
keep_result >= threshold keep call and result stay, byte for byte
keep_call >= threshold (result below) trim call stays; result is cut to --trim-head-chars plus one note line
both below threshold drop call and result are removed together

Defaults: --threshold 0.5, --trim-head-chars 300. A result is never left without its call, and a call is never dropped without its result.

3. Pinning (what is never touched)

The first message and the newest --preserve-recent messages (default 6) are pinned: their calls are not scored and not mutated, and they appear in the report as source: pin with reason pinned_first_message / pinned_recent. Pinning is the honest way to protect a live turn: the objective and the tool you just ran are usually the ones that matter most.

4. Budgets and fitting

The judge is shown the whole conversation, oldest first, with every tool result replaced by a short note (ok, 4213 chars) — inputs and texts stay, payloads do not. That state is resent with each request, so it is fitted into --max-state-tokens (default 25000) in stages, cheapest first:

Stage What changes
full as written
inputs-200 tool inputs cut to 200 chars; long non-pinned texts abridged (head + tail)
calls-one-line non-pinned calls become one line each; old call-only messages collapse

If even the last stage does not fit, the pass fails closed (TranscriptFitError, exit 2) instead of judging a conversation the judge could not see. Questions are split into as many requests as needed so state plus one batch stays under --max-request-tokens (default 30000, Jev’s request limit is 32K); --batch-size caps calls per request, --max-workers runs requests concurrently.

5. Fail-safe behaviour (never silent)

Situation provider_used degraded semantic_guarantee What happens
no key / LCC_DISABLE_NETWORK=1 jev true none every call kept, reason jev_unavailable_fail_safe
a batch fails degraded when nothing was judged, jev+partial_fail_safe otherwise true none / partial unjudged calls kept, judged calls keep their verdict
answer missing / malformed / outside 0–1 — — — that call is kept and the reason is a warning (jev_missing_answer:…, jev_answer_out_of_range:…)
nothing to score (all pinned) jev false none reason no_candidates; no call is claimed as judged

mechanical is refused (exit 2) in this mode: “is this tool output still needed” is a semantic question, and a lexical scorer cannot answer it honestly. Use --provider jev, laya (local judge), auto (prefers Jev), or rules (offline policy, below).

Offline policy: --provider rules

No key, no model, no network. Three modes (rules_mode, MCP argument / Python API):

lossless (default) — src/lcc/relevance/transcript_lossless.py. A tool result is replaced by a pointer to a later result that is kept unchanged and has the same is_error flag: [identical output kept at <id>] when the two are the same, or [output = lines a-b of <id>] when the original is exactly raw lines a..b (1-based, inclusive) of <id>. The note alone rebuilds the original, wherever <id> came from. Calls, inputs, texts and messages are never touched; nothing is trimmed; a pointer never targets a replaced result. The only lines ignored are the Codex Chunk ID: / Wall time: / Original token count: header lines (exit codes are compared). verify_lossless(original, compacted) re-checks each note’s exact claim from the two payloads; the pass runs it on its own output and keeps everything if it reports a violation. semantic_guarantee is lossless; decisions are keep or dedupe (with kept_at).

recoverable — src/lcc/relevance/transcript_recoverable.py. Everything lossless does, plus an old result may become [removed: re-read <path> (lines a-b) to recover] (a single-file read whose path no later non-read call names or writes) or [removed: re-run `<cmd>` to recover] (a command on the read-only allowlist: ls, find without -exec/-delete, rg, grep, git status|diff|log|show, cat/head/tail/sed -n 'a,bp', wc, tree without -o, pwd, <tool> --version; no pipes, redirects, substitutions or env prefixes). Never replaced: other commands, MCP/web tools, edit results, failed results, the latest error and the call before it, the last preserve_recent turns, calls named in the goal or recent user turns. Decisions are replace (with note and recover), dedupe or keep; verify_recoverable checks texts/inputs are unchanged and each pointer names its own call’s path or allowlisted command. Command pointers are off unless allow_command_pointers.

The file-pointer check has two forms (recoverability_check in the report):

Recommended config for live sessions: provider: rules, rules_mode: recoverable, workspace_root = the session’s cwd (the Claude Code hook sends it), command pointers off. lossless stays the default rules_mode: it needs no disk and makes no recovery claim. What each mode measured on real agent sessions: REAL_SESSION_BENCHMARKS.md.

lossy (EXPERIMENTAL) — failed two session-disjoint blind audits (bench v0.4: 21/150 decisions flagged; v0.5: 36/132), because its trims and drops remove output an agent may still need. A deterministic policy (src/lcc/relevance/transcript_rules.py) decides each unpinned call from the transcript’s structure and records the rule as the reason: it keeps the latest error result and the call before it, the latest git status|diff|log, plan/todo, edit, question to the user, the most recent result, and calls on files/URLs named in the goal or recent turns; it drops outputs superseded by a later successful whole-file Read/Write of the same file (a partial Read with offset/limit or a failed one never supersedes) or the same call run again with the same output, ignoring Codex chunk/timing header lines (superseded_by_later_call:<id>; a rerun whose output changed, such as a poll or a test before and after a fix, is not dropped) and successful reads far from recent work (stale_read_far_from_recent_work); it trims other outputs over 4000 chars to a 1000-char head + 1000-char tail (large_old_output_trimmed), except outputs from the last 40 messages, write_stdin/wait polls, error-bearing outputs, reads of a file a later edit or apply_patch body (sent through any tool) changes, and calls naming a path from the goal or recent turns (trim_guard_*, kept whole). semantic_guarantee is none. Pass is_error: true on a tool result when the host knows it failed: when any result carries the flag it alone marks errors; otherwise an error regex is applied to command output only, never to Read/Grep of source.

6. CLI options

Honored: --question, --provider jev|laya|auto|rules, --threshold, --trim-head-chars, --preserve-recent, --max-state-tokens, --max-request-tokens, --batch-size, --max-workers, --min-reduction (sets worth_it), --model (token counting), --jev-model, -o/--output, -r/--report, --dry-run.

Refused with exit 2 (they would otherwise be silently ignored): --trim-threshold, --preserve-tail, --min-block-chars, --keep-regex, --protect-prefix, --prefix-marker, --decisions-cache, --no-marker, --marker-scores, --append-to, --require-exact-tokens, --semantic-verify, --no-sufficiency, --max-restorations, --confidence-threshold, and the --laya-* family.

7. Report and audit

The report is transcript-compaction-1.0 with "mode": "tool-calls". It carries the same keys the block report uses where they map (decisions, blocks_* counts a call pair as one block, chars_before/chars_after, reduction_ratio, tokens_*, worth_it, semantic_guarantee, warnings), plus mode-specific ones: messages_before/messages_after, tool_calls_total/kept/trimmed/dropped, tool_chars_before/after, tool_reduction_ratio (the judged payload only), state_tokens, state_fit_stage, requests. Because decisions keeps the block schema, lcc explain report.json works on it unchanged, offline.

8. Python and MCP

from lcc.relevance import TranscriptCompactionRequest, compact_transcript, messages_to_payload

result = compact_transcript(
    TranscriptCompactionRequest(
        payload=transcript,          # dict or list
        question="finish the parser fix",
        provider="jev",              # needs TYPESAFE_API_KEY
        preserve_recent=6,
    )
)
print(result.report["tool_calls_dropped"], result.report["tool_reduction_ratio"])
compacted_messages = messages_to_payload(result.messages)

MCP clients get the same pass as the tool compact_transcript (see docs/MCP.md).

9. Limits, stated plainly

Measured against the reference implementation (fast-jev-compaction 0.4.0) on the same three synthetic transcripts, with matching options and live Jev: this mode removed 13.3 / 39.2 / 55.7% of the transcript at fact recall 1.00 / 1.00 / 1.00, while that library removed 70.5 / 78.9 / 83.4% at recall 0.00 — it dropped every evidence pair. Reduction alone would rank them backwards. Method, stability runs and limits: benchmarks/research/TRANSCRIPT_AB.md; the canonical row is in benchmarks/research/RESEARCH_STATUS.md. The largest session ranged 55.7–61.5% across four runs, and one of those runs dropped one evidence pair, which is why a pair only falls when both questions score low.