Skip to content

fix(compression): skip CCR on tool outputs to preserve agent loop - #7869

Merged
diegosouzapw merged 2 commits into
cheaper-inference:release/v3.8.49from
herjarsa:fix/ccr-skip-tool-outputs
Jul 20, 2026
Merged

diegosouzapw merged 2 commits into
cheaper-inference:release/v3.8.49from
herjarsa:fix/ccr-skip-tool-outputs

Conversation

@herjarsa

Copy link
Copy Markdown
Contributor

Problem

When OmniRoute is used as a chat-completion provider (e.g. by OpenCode, Claude Code, or any OpenAI-compatible client that points its base URL at /v1), the upstream LLM has no access to the omniroute_ccr_retrieve MCP tool. The MCP tool exists at open-sse/mcp-server/tools/compressionTools.ts:404, but it is only reachable when a client connects to OmniRoute as an MCP server, not as a chat provider.

CCR (Content-Compression-Retrieve) blindly replaces any non-system message whose content is >= minChars (default 600) with [CCR retrieve hash=<24hex> chars=<N>]. That includes:

  • OpenAI tool outputs: { role: "tool", tool_call_id, content }
  • Anthropic tool results: { role: "user", content: [{ type: "tool_result", … }] }

When the upstream LLM sees these markers in the tool output, it cannot expand them. The agent stalls — the LLM has an opaque placeholder where the actual bash / read / grep result should be, and has no way to recover.

Reproduces after ~50 turns once the context window starts to fill, because the retrieval ramp factor raises the effective minChars linearly per prior retrieval and progressively smaller blocks fall into the trap.

Fix

Extend processMessages in open-sse/services/compression/engines/ccr/index.ts to skip tool outputs verbatim:

// H-fix1: skip tool outputs (OpenAI `role:"tool"` and Anthropic user
// messages whose content is exclusively `tool_result` parts). When
// OmniRoute is used as a chat-completion PROVIDER, the upstream LLM has no
// way to call `omniroute_ccr_retrieve` and expand markers — replacing tool
// outputs with `[CCR retrieve hash=…]` placeholders therefore breaks the agent
// loop. Preserve tool outputs verbatim so the LLM can keep reasoning.
if (msg.role === "tool") return { ...msg };
if (msg.role === "user" && Array.isArray(msg.content)) {
  const parts = msg.content as Array<Record<string, unknown>>;
  if (parts.length > 0 && parts.every((p) => p["type"] === "tool_result")) {
    return { ...msg };
  }
}

CCR still applies to plain user / assistant text blocks, where the LLM can reason about the marker (and where the token savings are meaningful).

Tests

  • New tests/unit/compression/ccr-skip-tool-outputs.test.ts (4 cases):
    1. role:"tool" (OpenAI) is not compressed
    2. role:"user" with only tool_result parts (Anthropic) is not compressed
    3. Regression guard: plain user text is still compressed (over-apply guard)
    4. Edge case: single-element tool_result part is also skipped

All 57 pre-existing CCR tests still pass (npm run test:all not needed for this surgical change).

Risk

Low. The fix is additive skip — it only widens the existing role:"system" skip rule to two additional message shapes. Other engines in the stack (session-dedup, rtk, lite, caveman, ionizer, headroom, etc.) are untouched.

Out of scope (future work)

  • Server-side expansion of CCR markers in the response stream so the LLM can act on them without needing the MCP tool.
  • System-prompt injection telling the LLM what CCR markers mean and how to ignore them.
  • Wiring omniroute_ccr_retrieve into the upstream provider's function-calling schema.

These are larger refactors; the minimal fix here stops the agent-loop breakage now.

Reproduction

# Set up OpenCode (or any OpenAI-compatible client) to use OmniRoute as provider
# at http://localhost:20128/v1, model = anything, send a long bash/read/grep
# tool output. Before this fix: upstream LLM stalls. After this fix: agent
# continues reasoning on the verbatim output.

@herjarsa
herjarsa requested a review from diegosouzapw as a code owner July 20, 2026 13:16

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a fix to skip CCR compression for tool outputs (OpenAI role: "tool" and Anthropic user messages containing exclusively tool_result parts) to prevent breaking the agent loop when OmniRoute is used as a chat-completion provider. It also adds comprehensive unit tests to verify this behavior. The reviewer suggested using optional chaining when checking the type property of parts in msg.content to prevent potential runtime crashes from unexpected non-object values.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +289 to +294
if (msg.role === "user" && Array.isArray(msg.content)) {
const parts = msg.content as Array<Record<string, unknown>>;
if (parts.length > 0 && parts.every((p) => p["type"] === "tool_result")) {
return { ...msg };
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential runtime crashes (e.g., TypeError: Cannot read properties of null (reading 'type')), use optional chaining (p?.["type"]) when checking the type property of each part. Since msg.content is parsed from external client input, it could contain null or other unexpected non-object values.

Suggested change
if (msg.role === "user" && Array.isArray(msg.content)) {
const parts = msg.content as Array<Record<string, unknown>>;
if (parts.length > 0 && parts.every((p) => p["type"] === "tool_result")) {
return { ...msg };
}
}
if (msg.role === "user" && Array.isArray(msg.content)) {
const parts = msg.content as Array<Record<string, unknown>>;
if (parts.length > 0 && parts.every((p) => p?.["type"] === "tool_result")) {
return { ...msg };
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Fixed in commit f994c5595 (pushed to fix/ccr-skip-tool-outputs).

Applied optional chaining in both places that dereferenced part["type"]:

  1. The new skip rule: p?.["type"] === "tool_result".
  2. The existing text-part compression path that I missed in the first pass — the new regression test caught it (TypeError at line 308, before the guard was added).

Added a regression test: does NOT crash on malformed user content with null / non-object parts passes a null entry inside the parts array.

All 5 tests in the new file pass, plus all 57 pre-existing CCR tests still pass.

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Reviewed and verified — this is a real, well-tested fix. Nice catch on the agent-loop stall.

I independently probed the claim by reverting only open-sse/services/compression/engines/ccr/index.ts to its pre-PR state while keeping your new test file: the role:"tool" case fails on the unpatched code (true !== false), and the null-guard case throws TypeError: Cannot read properties of null (reading 'type') — confirming both the primary fix and the follow-up gemini-review fix address real, previously-latent bugs. With the fix restored, all 5 new tests plus all 57 pre-existing CCR tests pass, and lint is clean on both changed files.

One small accuracy note for the record (not a blocker): the Anthropic-shape guard (content:[{type:"tool_result",...}]) turns out to be a no-op in the current codebase — the pre-existing per-part loop at ccr/index.ts:604-625 already only compresses parts with type==="text", so tool_result parts were already being skipped before this PR (I confirmed the corresponding test passes even on the unpatched base). Harmless to keep for clarity/defense-in-depth, just wanted to flag that the role:"tool" fix is where the actual bug lived.

No conflicts against release/v3.8.49 in either touched file — good to retarget and merge.

@diegosouzapw
diegosouzapw changed the base branch from main to release/v3.8.49 July 20, 2026 14:03
@herjarsa

Copy link
Copy Markdown
Contributor Author

Replying to diegosouzapw's review above:

Thanks for the verification — and especially for independently reverting ccr/index.ts to repro both bugs on the unpatched code. That's the strongest signal.

Acknowledged on the Anthropic-shape no-op note: you're right — the pre-existing per-part loop only acts on type==="text" parts, so the tool_result-only check on line 289 is technically defense-in-depth, not the actual bug site. Two reasons I'd still keep it:

  1. Future-proofing: if anyone ever changes the per-part loop to also process tool_result parts (e.g. to log them, redact them, or count them for stats), the explicit skip guard prevents a regression on the provider-mode agent-loop path.
  2. Symmetry with the OpenAI branch: the role:"tool" skip and the tool_result-only skip are conceptually one rule ("don't compress tool outputs, period"). Splitting them across the two formats keeps the intent grep-able from both angles.

Happy to fold it into a comment if you prefer, but the code-as-spec form is what I'd keep.

Retargeted base from main → release/v3.8.49 per your note that there are no conflicts against that branch. CI is re-running now; will report back on the Quality Gates / Quality Ratchet failures once they complete.

herjarsa added 2 commits July 20, 2026 16:27
When OmniRoute is used as a chat-completion PROVIDER (not as an MCP server),
the upstream LLM cannot call `omniroute_ccr_retrieve` to expand CCR markers
on demand. Replacing tool outputs with `[CCR retrieve hash=… chars=…]`
placeholders therefore makes the LLM stall — it sees an opaque marker
where the actual tool result should be and has no way to recover the
verbatim content.

Scope:
- OpenAI format: `{ role: "tool", tool_call_id, content }`
- Anthropic format: `{ role: "user", content: [{ type: "tool_result", … }] }`

Fix: extend `processMessages` in the CCR engine to skip both shapes
verbatim. The engine still applies to plain user / assistant text blocks,
which is where compression yields token savings AND the LLM can reason
about the marker.

Tests:
- New `tests/unit/compression/ccr-skip-tool-outputs.test.ts` covers both
  formats (4 cases) plus a regression guard that plain user text is still
  compressed.
- All 57 pre-existing CCR tests still pass.

Reported-by: herjarsa
Refs: AGENTS.md agent feedback — agent loop stalled on bash/read/grep
       outputs after CCR collapsed them to markers.
Apply gemini-code-assist review feedback on PR cheaper-inference#7869:
- Use optional chaining when reading `part["type"]` so malformed
  client payloads (null entries, non-object entries in the parts
  array) cannot throw `TypeError: Cannot read properties of null`.
- The skip rule (this branch) and the existing text-part compression
  path (the next branch) both get the guard, since both dereference
  `part["type"]` directly.

Test:
- New defensive case: a user message whose content array contains a
  `null` entry alongside a `tool_result` must not crash the engine.

Refs: gemini-code-assist review on cheaper-inference#7869 (PRR_kwDORPf6ys8AAAABGj5G7Q)
@herjarsa
herjarsa force-pushed the fix/ccr-skip-tool-outputs branch from f994c55 to ef689cc Compare July 20, 2026 14:28
@diegosouzapw

Copy link
Copy Markdown
Collaborator

Cross-checked this against the other two compression PRs in flight (#7863 OmniGlyph-for-Fable-5 and #7855 session-dedup memory bound): no semantic collision — the three touch different engines, and the stacked pipeline runs them at fixed stackPriority values none of the PRs change (session-dedup=3 → ccr=4 → omniglyph=90), with a fail-open per-step loop, so no two engines ever compete for the same bytes.

Concretely for the case this PR fixes: on a Fable 5 request carrying tool outputs, session-dedup runs first, CCR now skips the tool output (your fix — previously it became a [CCR retrieve hash=…] marker and stalled the agent loop), and OmniGlyph still runs afterwards. Correct end-to-end.

Suggested land order, purely for rollback hygiene: #7855 → this one → #7863.

@herjarsa

Copy link
Copy Markdown
Contributor Author

Acked on the cross-PR check + the stackPriority chain (session-dedup=3 → ccr=4 → omniglyph=90, fail-open per-step). The end-to-end ordering for a tool-output payload is exactly what I expected to happen but it's good to see it confirmed against the in-flight neighbors.

On the land order (#7855 → this → #7863): noted, no action needed from my side — this PR is branch-clean on release/v3.8.49 (cherry-picked off upstream/main, the unrelated conflict mass is gone), 62/62 CCR tests green locally, force-pushed to herjarsa:fix/ccr-skip-tool-outputs at ef689cc02, and mergeable: MERGEABLE with 0 failed checks (7 pending re-runs on the new base).

Standing by for whichever step of the train you want to trigger next — happy to hold while #7855 lands first per your call.

@diegosouzapw
diegosouzapw merged commit fca82af into cheaper-inference:release/v3.8.49 Jul 20, 2026
5 checks passed
@diegosouzapw

Copy link
Copy Markdown
Collaborator

Merged — thanks @herjarsa! Great catch on the agent-loop stall; the tool-output skip composes cleanly with the rest of the compression stack. Validated in the local merge-train (14-PR combined tree, full unit suite + vitest green) on tomni-proxmox-113 @ 0fda37bb4fdb26ba1345fd7b4cac44fbe235cda4, then squash-merged.

@diegosouzapw diegosouzapw mentioned this pull request Jul 23, 2026
HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
…eaper-inference#7869)

* fix(compression): skip CCR on tool outputs to preserve agent loop

When OmniRoute is used as a chat-completion PROVIDER (not as an MCP server),
the upstream LLM cannot call `omniroute_ccr_retrieve` to expand CCR markers
on demand. Replacing tool outputs with `[CCR retrieve hash=… chars=…]`
placeholders therefore makes the LLM stall — it sees an opaque marker
where the actual tool result should be and has no way to recover the
verbatim content.

Scope:
- OpenAI format: `{ role: "tool", tool_call_id, content }`
- Anthropic format: `{ role: "user", content: [{ type: "tool_result", … }] }`

Fix: extend `processMessages` in the CCR engine to skip both shapes
verbatim. The engine still applies to plain user / assistant text blocks,
which is where compression yields token savings AND the LLM can reason
about the marker.

Tests:
- New `tests/unit/compression/ccr-skip-tool-outputs.test.ts` covers both
  formats (4 cases) plus a regression guard that plain user text is still
  compressed.
- All 57 pre-existing CCR tests still pass.

Reported-by: herjarsa
Refs: AGENTS.md agent feedback — agent loop stalled on bash/read/grep
       outputs after CCR collapsed them to markers.

* fix(compression): guard CCR against null / non-object parts

Apply gemini-code-assist review feedback on PR cheaper-inference#7869:
- Use optional chaining when reading `part["type"]` so malformed
  client payloads (null entries, non-object entries in the parts
  array) cannot throw `TypeError: Cannot read properties of null`.
- The skip rule (this branch) and the existing text-part compression
  path (the next branch) both get the guard, since both dereference
  `part["type"]` directly.

Test:
- New defensive case: a user message whose content array contains a
  `null` entry alongside a `tool_result` must not crash the engine.

Refs: gemini-code-assist review on cheaper-inference#7869 (PRR_kwDORPf6ys8AAAABGj5G7Q)

---------

Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…eaper-inference#7869)

* fix(compression): skip CCR on tool outputs to preserve agent loop

When OmniRoute is used as a chat-completion PROVIDER (not as an MCP server),
the upstream LLM cannot call `omniroute_ccr_retrieve` to expand CCR markers
on demand. Replacing tool outputs with `[CCR retrieve hash=… chars=…]`
placeholders therefore makes the LLM stall — it sees an opaque marker
where the actual tool result should be and has no way to recover the
verbatim content.

Scope:
- OpenAI format: `{ role: "tool", tool_call_id, content }`
- Anthropic format: `{ role: "user", content: [{ type: "tool_result", … }] }`

Fix: extend `processMessages` in the CCR engine to skip both shapes
verbatim. The engine still applies to plain user / assistant text blocks,
which is where compression yields token savings AND the LLM can reason
about the marker.

Tests:
- New `tests/unit/compression/ccr-skip-tool-outputs.test.ts` covers both
  formats (4 cases) plus a regression guard that plain user text is still
  compressed.
- All 57 pre-existing CCR tests still pass.

Reported-by: herjarsa
Refs: AGENTS.md agent feedback — agent loop stalled on bash/read/grep
       outputs after CCR collapsed them to markers.

* fix(compression): guard CCR against null / non-object parts

Apply gemini-code-assist review feedback on PR cheaper-inference#7869:
- Use optional chaining when reading `part["type"]` so malformed
  client payloads (null entries, non-object entries in the parts
  array) cannot throw `TypeError: Cannot read properties of null`.
- The skip rule (this branch) and the existing text-part compression
  path (the next branch) both get the guard, since both dereference
  `part["type"]` directly.

Test:
- New defensive case: a user message whose content array contains a
  `null` entry alongside a `tool_result` must not crash the engine.

Refs: gemini-code-assist review on cheaper-inference#7869 (PRR_kwDORPf6ys8AAAABGj5G7Q)

---------

Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants