Skip to content

Commit fed2a9b

Browse files
committed
feat(compact): add web-fetches section to compaction manifest
Session web_history (WebEntry objects) was tracked but never surfaced in the PreCompact manifest. When an agent fetches docs or API references mid-session and the conversation compacts, that context was silently lost even though the body remains on disk via the web-output cache. Changes: - Add _select_top_web_entries() and _format_web_entry() helpers, mirroring the bash equivalents. Entries below _MIN_WEB_BYTES_FOR_MANIFEST (200 B) are filtered; up to _MAX_WEB_ENTRIES (4) are ranked by recency. - Wire a "### Web Fetches (cached body)" section into _render(), positioned after bash history and before grep patterns. - Update _section_budgets() to include a "web" slice (10%); reduce "files" from 30% to 25% and "bash" from 15% to 10% to keep the total at 100%. - Extend the early-exit check in _render() to count web_history as activity so a session whose only content is web fetches still produces a manifest. - Add web_bonus (+15 tokens) to compute_adaptive_budget() so sessions with web fetches get a slightly larger budget. - New tests in test_compact_web.py covering section emission, cache-ID inclusion, tiny-fetch suppression, young-session suppression, status code and truncated-marker rendering, bash+web coexistence, recency ranking, and _select_top_web_entries/_format_web_entry unit tests. - Update test_compact.py section-budget assertions to reflect the new 5-way split (symbols=40%, files=25%, greps=15%, bash=10%, web=10%). - Update test_compaction_size_budgets.py and test_new_features.py to align with the smaller bash budget slice and the mature-tier age requirement for bash/web sections to appear.
1 parent a7e6e1f commit fed2a9b

5 files changed

Lines changed: 446 additions & 29 deletions

File tree

src/token_goat/compact.py

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@
6060
# tokens that would not be paid back even if the agent acted on the hint.
6161
_MIN_BASH_BYTES_FOR_MANIFEST: Final[int] = 400
6262

63+
# Maximum web fetches listed in the "Web Fetches" section of the manifest.
64+
# Web fetches capture documentation, API responses, and external context the
65+
# agent loaded mid-session. Four entries cover the common case (fetch a docs
66+
# page, maybe an API reference or two) without crowding the bash section.
67+
_MAX_WEB_ENTRIES: Final[int] = 4
68+
# Smallest cached web body worth surfacing in the manifest. Small fetches
69+
# (redirects, tiny JSON blobs) don't pay back the manifest line's token cost.
70+
_MIN_WEB_BYTES_FOR_MANIFEST: Final[int] = 200
71+
6372
# Sentinel gap used by session.mark_file_read() when no line limit is specified.
6473
# A range whose (end - start) equals this value represents "whole file read, extent
6574
# unknown" — _format_ranges() annotates these as "(full)" rather than printing
@@ -642,6 +651,54 @@ def _format_bash_entry(entry: object) -> str:
642651
)
643652

644653

654+
def _select_top_web_entries(web_history: object) -> list[object]:
655+
"""Pick up to :data:`_MAX_WEB_ENTRIES` web fetches worth surfacing in the manifest.
656+
657+
Filters entries below :data:`_MIN_WEB_BYTES_FOR_MANIFEST` (tiny redirects
658+
and empty responses provide no recoverable context) and ranks by recency —
659+
the most recently fetched pages are the ones whose content is most likely
660+
to drive the next agent turn.
661+
662+
Accepts ``web_history`` typed as ``object`` to remain safe on legacy
663+
SessionCache instances that predate the field (``None`` / missing → empty list).
664+
Entries are :class:`session.WebEntry` instances accessed via :func:`getattr`.
665+
"""
666+
if not isinstance(web_history, dict) or not web_history:
667+
return []
668+
candidates = [
669+
e for e in web_history.values()
670+
if getattr(e, "body_bytes", 0) >= _MIN_WEB_BYTES_FOR_MANIFEST
671+
]
672+
if not candidates:
673+
return []
674+
return heapq.nlargest(_MAX_WEB_ENTRIES, candidates, key=lambda e: getattr(e, "ts", 0.0))
675+
676+
677+
def _format_web_entry(entry: object) -> str:
678+
"""Render one :class:`session.WebEntry` as a single manifest line.
679+
680+
Format::
681+
682+
- 🌐 https://docs.example.com/api (200, 14.2KB, id=abc123...)
683+
- 🌐 https://example.com/page (404, 0.5KB, id=def456...)
684+
685+
The cache ID is included so the compaction LLM can hand the agent
686+
``token-goat web-output <id>`` to recover the body without re-fetching.
687+
Status code distinguishes successful fetches from error responses so the
688+
LLM knows whether the cached body is useful content or an error page.
689+
"""
690+
url_preview = sanitize_log_str(getattr(entry, "url_preview", ""), max_len=100)
691+
body_bytes = int(getattr(entry, "body_bytes", 0))
692+
status_code = getattr(entry, "status_code", None)
693+
output_id = sanitize_log_str(getattr(entry, "output_id", ""), max_len=24)
694+
truncated_marker = " (truncated)" if getattr(entry, "truncated", False) else ""
695+
status_str = str(status_code) if status_code is not None else "?"
696+
return (
697+
f"- 🌐 {url_preview} "
698+
f"({status_str}, {_humanize_bytes(body_bytes)}{truncated_marker}, id={output_id})"
699+
)
700+
701+
645702
def _token_count(text: str) -> int:
646703
"""Rough token estimate: 1 token ≈ 4 characters.
647704
@@ -684,9 +741,10 @@ def _section_budgets(total_budget: int, edited_tokens: int) -> dict[str, int]:
684741
# Proportions must sum to 1.0.
685742
proportions: dict[str, float] = {
686743
"symbols": 0.40,
687-
"files": 0.30,
744+
"files": 0.25,
688745
"greps": 0.15,
689-
"bash": 0.15,
746+
"bash": 0.10,
747+
"web": 0.10,
690748
}
691749
budgets: dict[str, int] = {}
692750
for name, ratio in proportions.items():
@@ -887,7 +945,10 @@ def compute_adaptive_budget(cache: SessionCache, age_seconds: float = 0.0) -> in
887945
# Bash history bonus: 20 tokens if there are any entries
888946
bash_bonus = 20 if (getattr(cache, "bash_history", None) and cache.bash_history) else 0
889947

890-
raw_total = base + edited_bonus + symbols_bonus + bash_bonus
948+
# Web history bonus: 15 tokens if there are any cached web fetches
949+
web_bonus = 15 if (getattr(cache, "web_history", None) and cache.web_history) else 0
950+
951+
raw_total = base + edited_bonus + symbols_bonus + bash_bonus + web_bonus
891952

892953
# Apply session-age tier multiplier: young sessions need less manifest space
893954
# (little context has accumulated); mature sessions need more.
@@ -915,13 +976,14 @@ def build_manifest_adaptive(session_id: str) -> str:
915976
age_seconds = max(0.0, time.time() - created_ts) if created_ts is not None else 0.0
916977
budget = compute_adaptive_budget(cache, age_seconds=age_seconds)
917978
_LOG.debug(
918-
"build_manifest_adaptive: session=%s budget=%d tier=%s (edited=%d symbols=%d bash=%s)",
979+
"build_manifest_adaptive: session=%s budget=%d tier=%s (edited=%d symbols=%d bash=%s web=%s)",
919980
session_id[:8],
920981
budget,
921982
_session_age_tier(age_seconds),
922983
len(cache.edited_files) if isinstance(cache.edited_files, dict) else 0,
923984
sum(1 for e in cache.files.values() if e.symbols_read),
924985
bool(getattr(cache, "bash_history", None) and cache.bash_history),
986+
bool(getattr(cache, "web_history", None) and cache.web_history),
925987
)
926988
return _build_manifest_from_cache(cache, session_id, budget)
927989

@@ -1093,7 +1155,9 @@ def _render(cache: SessionCache, session_id: str, max_tokens: int) -> tuple[str,
10931155
raw_greps = getattr(cache, "greps", None) or []
10941156
_raw_bash = getattr(cache, "bash_history", None)
10951157
raw_bash: dict = _raw_bash if isinstance(_raw_bash, dict) else {}
1096-
if not edited_clean and not files_clean and not raw_greps and not raw_bash:
1158+
_raw_web = getattr(cache, "web_history", None)
1159+
raw_web: dict = _raw_web if isinstance(_raw_web, dict) else {}
1160+
if not edited_clean and not files_clean and not raw_greps and not raw_bash and not raw_web:
10971161
_LOG.info(
10981162
"_render: manifest suppressed for session=%s "
10991163
"(no activity tracked: edited=0 files_read=0 greps=0 bash=0)",
@@ -1326,6 +1390,30 @@ def _render(cache: SessionCache, session_id: str, max_tokens: int) -> tuple[str,
13261390
if bash_used + _token_count(overflow_line) <= bash_budget:
13271391
bash_lines.append(overflow_line)
13281392

1393+
# ── 3b. Web fetches — up to 10 % of remaining budget ─────────────────────
1394+
# Young sessions skip web sections — same rationale as bash_entries above.
1395+
web_budget = sec_budgets["web"]
1396+
web_lines: list[str] = []
1397+
web_used = 0
1398+
web_entries = (
1399+
_select_top_web_entries(raw_web)
1400+
if age_tier != "young"
1401+
else []
1402+
)
1403+
if web_entries:
1404+
header = "### Web Fetches (cached body)"
1405+
header_cost = _token_count(header)
1406+
if web_used + header_cost <= web_budget:
1407+
web_lines.append(header)
1408+
web_used += header_cost
1409+
for we in web_entries:
1410+
line = _format_web_entry(we)
1411+
cost = _token_count(line)
1412+
if web_used + cost > web_budget:
1413+
break
1414+
web_lines.append(line)
1415+
web_used += cost
1416+
13291417
# ── 4. Grep patterns — up to 15 % of remaining budget ────────────────────
13301418
grep_budget = sec_budgets["greps"]
13311419
grep_lines: list[str] = []
@@ -1424,6 +1512,7 @@ def _basename(p: str) -> str:
14241512
+ stale_lines
14251513
+ sym_lines
14261514
+ bash_lines
1515+
+ web_lines
14271516
+ grep_lines
14281517
+ files_lines
14291518
)
@@ -1446,9 +1535,9 @@ def _basename(p: str) -> str:
14461535
token_count = estimate_tokens(result)
14471536
_LOG.debug(
14481537
"_render: manifest assembled for session=%s; ~%d tokens (budget=%d) "
1449-
"sym=%d bash=%d grep=%d files=%d",
1538+
"sym=%d bash=%d web=%d grep=%d files=%d",
14501539
session_id[:8], token_count, max_tokens,
1451-
sym_used, bash_used, grep_used, files_used,
1540+
sym_used, bash_used, web_used, grep_used, files_used,
14521541
)
14531542

14541543
# Safety net: per-section budgets use _token_count (len//4, conservative) while

tests/test_compact.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1579,61 +1579,66 @@ class TestSectionBudgets:
15791579
def test_proportions_sum_to_total_remaining(self):
15801580
"""Allocated budgets collectively cover the full remaining budget."""
15811581
budgets = compact._section_budgets(400, 100)
1582-
# Remaining = 300; proportions 40/30/15/15 = 100%
1582+
# Remaining = 300; proportions 40/25/15/10/10 = 100%
15831583
# Each individual bucket may be slightly under due to int truncation,
15841584
# but the sum must be <= remaining (never overallocated).
15851585
assert sum(budgets.values()) <= 300
1586-
# And must be close — within 4 tokens of 300 (one int-rounding unit per bucket).
1587-
assert sum(budgets.values()) >= 300 - 4
1586+
# And must be close — within 5 tokens of 300 (one int-rounding unit per bucket).
1587+
assert sum(budgets.values()) >= 300 - 5
15881588

15891589
def test_symbols_gets_forty_percent(self):
15901590
"""Symbols section receives 40% of the remaining budget."""
15911591
budgets = compact._section_budgets(400, 0)
15921592
assert budgets["symbols"] == int(400 * 0.40)
15931593

1594-
def test_files_gets_thirty_percent(self):
1595-
"""Files section receives 30% of the remaining budget."""
1594+
def test_files_gets_twenty_five_percent(self):
1595+
"""Files section receives 25% of the remaining budget."""
15961596
budgets = compact._section_budgets(400, 0)
1597-
assert budgets["files"] == int(400 * 0.30)
1597+
assert budgets["files"] == int(400 * 0.25)
15981598

15991599
def test_greps_gets_fifteen_percent(self):
16001600
"""Greps section receives 15% of the remaining budget."""
16011601
budgets = compact._section_budgets(400, 0)
16021602
assert budgets["greps"] == int(400 * 0.15)
16031603

1604-
def test_bash_gets_fifteen_percent(self):
1605-
"""Bash section receives 15% of the remaining budget."""
1604+
def test_bash_gets_ten_percent(self):
1605+
"""Bash section receives 10% of the remaining budget."""
16061606
budgets = compact._section_budgets(400, 0)
1607-
assert budgets["bash"] == int(400 * 0.15)
1607+
assert budgets["bash"] == int(400 * 0.10)
1608+
1609+
def test_web_gets_ten_percent(self):
1610+
"""Web section receives 10% of the remaining budget."""
1611+
budgets = compact._section_budgets(400, 0)
1612+
assert budgets["web"] == int(400 * 0.10)
16081613

16091614
def test_edited_tokens_reduce_remaining(self):
16101615
"""Edited-section cost is subtracted before proportional split."""
16111616
budgets_no_edit = compact._section_budgets(400, 0)
16121617
budgets_with_edit = compact._section_budgets(400, 100)
16131618
# Each section should be smaller when 100 tokens are pre-consumed.
1614-
for key in ("symbols", "files", "greps", "bash"):
1619+
for key in ("symbols", "files", "greps", "bash", "web"):
16151620
assert budgets_with_edit[key] < budgets_no_edit[key]
16161621

16171622
def test_minimum_section_tokens_enforced(self):
16181623
"""Every section gets at least the minimum even with a tiny budget."""
16191624
# 10-token budget with 9 tokens already consumed → 1 token remaining.
16201625
# Each section must still get at least 20 tokens (the minimum floor).
16211626
budgets = compact._section_budgets(10, 9)
1622-
for key in ("symbols", "files", "greps", "bash"):
1627+
for key in ("symbols", "files", "greps", "bash", "web"):
16231628
assert budgets[key] >= 20, (
16241629
f"section {key!r} got {budgets[key]} tokens, expected >= 20"
16251630
)
16261631

16271632
def test_zero_remaining_gives_minimums(self):
16281633
"""When edited section consumes the entire budget, sections get minimums."""
16291634
budgets = compact._section_budgets(400, 500) # edited_tokens > total
1630-
for key in ("symbols", "files", "greps", "bash"):
1635+
for key in ("symbols", "files", "greps", "bash", "web"):
16311636
assert budgets[key] >= 20
16321637

1633-
def test_returns_all_four_keys(self):
1634-
"""Return dict always contains exactly the four expected keys."""
1638+
def test_returns_all_five_keys(self):
1639+
"""Return dict always contains exactly the five expected keys."""
16351640
budgets = compact._section_budgets(400, 100)
1636-
assert set(budgets.keys()) == {"symbols", "files", "greps", "bash"}
1641+
assert set(budgets.keys()) == {"symbols", "files", "greps", "bash", "web"}
16371642

16381643
def test_manifest_stays_within_budget_simple_session(self, tmp_data_dir):
16391644
"""A simple session manifest stays within the requested token budget."""

0 commit comments

Comments
 (0)