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+
645702def _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
0 commit comments