0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
226 lines
9.3 KiB
Python
226 lines
9.3 KiB
Python
"""Loop detection for Headroom Learn — find repeated tool-call patterns.
|
|
|
|
A *loop* is the single highest-value pattern for `headroom learn` to catch,
|
|
because its token waste scales with the number of repetitions rather than
|
|
being a one-time cost. Two loop shapes matter:
|
|
|
|
1. **Error loops** — the same call fails, the agent retries, it fails again
|
|
(e.g. a wrong path read N times). Every repetition is pure waste.
|
|
|
|
2. **RTK re-fetch loops** — RTK (Realtime Token Kompress) rewrites a shell
|
|
command to truncate its output (``grep foo`` → ``grep foo | head -50``).
|
|
When the truncation drops what the agent needed, the agent re-runs a
|
|
*variant* of the same command to fetch more (``head -100``, a new offset,
|
|
a narrower pattern). Each call succeeds (``is_error=False``) but returns
|
|
insufficient output, so the loop is invisible to failure-only analysis.
|
|
See ``docs/rtk-architecture.md`` for why RTK truncates commands.
|
|
|
|
This module collapses such variants to a canonical signature, counts the
|
|
repetitions, and measures the wasted tokens so the analyzer can (a) surface
|
|
loops to the LLM and (b) weight loop-derived recommendations above one-offs.
|
|
The analyzer historically ranked recommendations purely by an LLM-guessed
|
|
``estimated_tokens_saved`` with a flat confidence — loops had no special
|
|
weight at all.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
|
|
from .models import Recommendation, SessionData, ToolCall
|
|
|
|
# Minimum repetitions of one signature before it counts as a loop. Three is the
|
|
# smallest count that distinguishes a loop ("again, and again") from a one-off
|
|
# retry ("that failed once, try once more") — matching the analyzer's existing
|
|
# "2+ occurrences or explicit user direction" evidence bar but one stricter so
|
|
# a single retry is not mislabeled a loop.
|
|
DEFAULT_MIN_OCCURRENCES = 3
|
|
|
|
# Rough bytes-per-token used to convert measured output sizes into a token
|
|
# estimate. The analyzer's digest builder uses the same 4:1 approximation.
|
|
_BYTES_PER_TOKEN = 4
|
|
|
|
# Pagination / output-limiting fragments that vary between RTK re-fetch
|
|
# attempts but do NOT change which command is being run. Stripping these is
|
|
# what collapses ``grep foo | head -50`` and ``grep foo | head -100`` to one
|
|
# signature. Order-independent: applied as a global substitution.
|
|
_PAGINATION_PATTERNS = [
|
|
r"\|\s*head\s+-n?\s*\d+", # | head -50, | head -n 50
|
|
r"\|\s*tail\s+-n?\s*\d+", # | tail -50
|
|
r"-n\s*\d+", # -n 50 (git log -n 50, grep -n is rare but harmless here)
|
|
r"--max-count[= ]\d+", # grep --max-count=50
|
|
r"--lines[= ]\d+",
|
|
r"\bhead\s+-\d+", # head -50
|
|
r"\b(limit|offset)[= ]\d+", # LIMIT 50 / offset=100 (sql-ish)
|
|
r"\bLIMIT\s+\d+",
|
|
r"\bOFFSET\s+\d+",
|
|
]
|
|
_PAGINATION_RE = re.compile("|".join(_PAGINATION_PATTERNS), re.IGNORECASE)
|
|
|
|
# Collapse any remaining bare integers so e.g. line numbers / byte offsets in
|
|
# otherwise identical commands do not split a loop into singletons.
|
|
_INT_RE = re.compile(r"\b\d+\b")
|
|
_WS_RE = re.compile(r"\s+")
|
|
|
|
|
|
@dataclass
|
|
class LoopPattern:
|
|
"""A repeated tool-call pattern detected within a session.
|
|
|
|
``wasted_tokens`` is a *measured* lower bound (from real output sizes),
|
|
not an LLM guess — for an N-occurrence loop it counts the N-1 redundant
|
|
repetitions, since the first call is legitimate work.
|
|
"""
|
|
|
|
tool: str
|
|
signature: str # Canonical, variant-collapsed signature
|
|
sample_input: str # A human-readable example of the looped call
|
|
count: int
|
|
is_error_loop: bool
|
|
wasted_tokens: int
|
|
msg_indices: list[int] = field(default_factory=list)
|
|
|
|
@property
|
|
def kind(self) -> str:
|
|
return "error-loop" if self.is_error_loop else "rtk-refetch-loop"
|
|
|
|
|
|
def _canonical_signature(tc: ToolCall) -> str:
|
|
"""Collapse a tool call to a signature stable across re-fetch variants.
|
|
|
|
For shell commands this strips pagination/limit fragments and bare
|
|
integers so RTK truncation variants of the same command map together.
|
|
For other tools the input summary is normalized on whitespace only.
|
|
"""
|
|
raw = tc.input_summary.strip()
|
|
if tc.name.lower() in ("bash", "shell"):
|
|
raw = _PAGINATION_RE.sub(" ", raw)
|
|
raw = _INT_RE.sub("N", raw)
|
|
raw = _WS_RE.sub(" ", raw).strip().lower()
|
|
return f"{tc.name.lower()}::{raw}"
|
|
|
|
|
|
def _tokens(tc: ToolCall) -> int:
|
|
"""Token estimate for a single call's output."""
|
|
nbytes = tc.output_bytes or len(tc.output)
|
|
return nbytes // _BYTES_PER_TOKEN
|
|
|
|
|
|
def detect_loops(
|
|
sessions: list[SessionData],
|
|
*,
|
|
min_occurrences: int = DEFAULT_MIN_OCCURRENCES,
|
|
) -> list[LoopPattern]:
|
|
"""Detect repeated tool-call patterns across sessions.
|
|
|
|
Calls are grouped by canonical signature *within each session* (a loop is
|
|
a within-conversation phenomenon; the same command in two unrelated
|
|
sessions is not a loop). Groups meeting ``min_occurrences`` become
|
|
``LoopPattern`` results, sorted by measured wasted tokens descending.
|
|
"""
|
|
groups: dict[str, list[ToolCall]] = {}
|
|
for session in sessions:
|
|
per_session: dict[str, list[ToolCall]] = {}
|
|
for tc in session.tool_calls:
|
|
per_session.setdefault(_canonical_signature(tc), []).append(tc)
|
|
# Merge each session's qualifying groups into the global view keyed by
|
|
# signature so cross-session recurrence of the SAME loop accumulates.
|
|
for sig, calls in per_session.items():
|
|
if len(calls) >= min_occurrences:
|
|
groups.setdefault(sig, []).extend(calls)
|
|
|
|
loops: list[LoopPattern] = []
|
|
for sig, calls in groups.items():
|
|
count = len(calls)
|
|
is_error_loop = sum(1 for c in calls if c.is_error) >= (count / 2)
|
|
if is_error_loop:
|
|
# Every repetition of a failing call is waste — including the first,
|
|
# since with upfront knowledge it would never have run.
|
|
wasted = sum(_tokens(c) for c in calls)
|
|
else:
|
|
# Re-fetch loop: the first call is legitimate; the N-1 follow-ups
|
|
# are the redundant re-fetches RTK truncation provoked.
|
|
per_call = sorted((_tokens(c) for c in calls), reverse=True)
|
|
wasted = sum(per_call[1:])
|
|
loops.append(
|
|
LoopPattern(
|
|
tool=calls[0].name,
|
|
signature=sig,
|
|
sample_input=calls[0].input_summary[:120],
|
|
count=count,
|
|
is_error_loop=is_error_loop,
|
|
wasted_tokens=wasted,
|
|
msg_indices=sorted(c.msg_index for c in calls),
|
|
)
|
|
)
|
|
|
|
loops.sort(key=lambda lp: lp.wasted_tokens, reverse=True)
|
|
return loops
|
|
|
|
|
|
def format_loops_for_digest(loops: list[LoopPattern]) -> str:
|
|
"""Render detected loops as a high-priority digest section for the LLM.
|
|
|
|
Returns "" when there are no loops so the digest is unchanged in the
|
|
common case.
|
|
"""
|
|
if not loops:
|
|
return ""
|
|
lines = [
|
|
"=== Detected Loops (HIGHEST PRIORITY) ===",
|
|
(
|
|
"These tool-call patterns REPEATED within a session — the most "
|
|
"expensive kind of waste, since cost scales with repetition. A rule "
|
|
"that prevents a loop is worth far more than one that prevents a "
|
|
"one-off error. Emit a guardrail for EACH loop below and set its "
|
|
"estimated_tokens_saved to at least the measured wasted tokens shown."
|
|
),
|
|
"",
|
|
]
|
|
for lp in loops:
|
|
lines.append(
|
|
f'- [{lp.kind}] {lp.tool}: "{lp.sample_input}" '
|
|
f"repeated {lp.count}x, ~{lp.wasted_tokens:,} tokens wasted "
|
|
f"(messages {lp.msg_indices})"
|
|
)
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _signature_tokens(signature: str) -> set[str]:
|
|
"""Word tokens from a canonical signature, for fuzzy rule matching."""
|
|
body = signature.split("::", 1)[-1]
|
|
return {t for t in re.split(r"[^a-z0-9]+", body) if len(t) > 2}
|
|
|
|
|
|
def apply_loop_weighting(recommendations: list[Recommendation], loops: list[LoopPattern]) -> None:
|
|
"""Boost recommendations that address a detected loop, in place.
|
|
|
|
The analyzer ranks recommendations by ``estimated_tokens_saved`` (an LLM
|
|
guess). For a recommendation whose text overlaps a detected loop's
|
|
signature, we raise that figure to at least the loop's *measured* wasted
|
|
tokens and tag it as loop-derived. Because measured loop waste aggregates
|
|
many repetitions, this reliably lifts loop guardrails above one-off rules
|
|
without trusting the LLM to have weighted them correctly.
|
|
"""
|
|
if not loops:
|
|
return
|
|
for rec in recommendations:
|
|
haystack = f"{rec.section} {rec.content}".lower()
|
|
best: LoopPattern | None = None
|
|
for lp in loops:
|
|
sig_tokens = _signature_tokens(lp.signature)
|
|
if not sig_tokens:
|
|
continue
|
|
overlap = sum(1 for t in sig_tokens if t in haystack)
|
|
# Require a majority of the signature's salient tokens to appear so
|
|
# we don't over-credit a generic rule.
|
|
if overlap >= max(1, (len(sig_tokens) + 1) // 2):
|
|
if best is None or lp.wasted_tokens > best.wasted_tokens:
|
|
best = lp
|
|
if best is not None:
|
|
rec.estimated_tokens_saved = max(rec.estimated_tokens_saved, best.wasted_tokens)
|
|
rec.is_loop_guardrail = True
|
|
rec.loop_occurrences = best.count
|