Spaces:
Running
Running
File size: 19,542 Bytes
9d0fd45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 | """Message-history compaction for the agent loop."""
from __future__ import annotations
import re
from collections.abc import Callable
from typing import Protocol, runtime_checkable
from frontier_agent.core.messages import (
Message,
is_assistant_msg,
is_tool_msg,
text_of,
tool_msg,
user_msg,
)
__all__ = [
"COMPACTION_SEQ_KEY",
"FORCE_COMPACTION_KEY",
"INPUT_ESTIMATE_KEY",
"OMITTED_TOOL_RESULT_PLACEHOLDER",
"SPILL_MANIFEST_HEADER",
"URL_RE",
"CompactionPolicy",
"DefaultCompactionPolicy",
"DefaultMessageCompactor",
"KeepLastNToolResultsCompactor",
"MessageCompactor",
"StringSliceCompactor",
"compact_messages",
"compress_tool_results",
"estimate_tokens",
]
# Observer-to-loop handshake: request one pass, then record only completed passes.
FORCE_COMPACTION_KEY = "_force_compaction"
COMPACTION_SEQ_KEY = "_compaction_seq"
# Loop-to-observer handshake: the token estimate of the message list actually
# handed to the provider this turn. An observer that samples the history itself
# cannot reproduce it β by turn end the list has grown by this turn's completion
# and tool results, and the per-call system addendum was never in it at all.
INPUT_ESTIMATE_KEY = "_input_token_estimate"
# The model reads this in place of a result it already consumed, with its own
# tool call still visible above it. "Omitted to save tokens" invites the
# obvious repair β call the same tool again β which is how a compacted
# research agent ends up re-issuing queries it already ran. Say plainly that
# re-calling cannot bring the result back.
OMITTED_TOOL_RESULT_PLACEHOLDER = (
"Tool result dropped to save tokens. You already read it; re-running the "
"same call will not restore it. Rely on your notes and later messages."
)
URL_RE = re.compile(r'https?://[^\s\)>"\'<]+')
_TOOL_RESULT_COMPACT_MAX_CHARS = 1_200
# Header of the spill recovery index. This is presentation only β the text the
# MODEL reads above the paths β since the index is identified by
# ``Message.spill_refs``. The two remaining substring checks against it
# (``compact_messages`` here, and the summarizer input filter) are the legacy path
# for a history checkpointed before that field existed, and can go once no such
# checkpoint can still be resumed.
#
# Lives here, not in ``tiered_compact``, because ``tiered_compact`` imports this
# module, so the dependency cannot go the other way.
SPILL_MANIFEST_HEADER = (
"[Read-only recovery index; use only for missing older detail. "
"Never write here.]"
)
def _tool_names_by_call_id(messages: list[Message]) -> dict[str, str]:
"""Map ``tool_call_id`` β tool name from AIMessage ``tool_calls``.
A ``ToolMessage`` carries no tool name, so any name-keyed policy has to
resolve it through the requesting assistant message.
"""
out: dict[str, str] = {}
for msg in messages:
if not is_assistant_msg(msg):
continue
for tc in msg.get("tool_calls") or []:
if not isinstance(tc, dict):
continue
fn = tc.get("function")
name = (fn.get("name") if isinstance(fn, dict) else None) or tc.get("name")
tid = tc.get("id") or (fn.get("id") if isinstance(fn, dict) else None)
if tid and name:
out[tid] = name
return out
def _condense(content: str, max_chars: int) -> str:
"""Head + tail + URLs of *content*, never longer than the original."""
prefix = f"[Compressed tool result: {len(content):,} characters]\n"
marker = "\nβ¦ [middle omitted] β¦\n"
url_lines: list[str] = []
url_budget = max_chars // 2
for url in dict.fromkeys(URL_RE.findall(content)):
candidate = "\n[Source URLs]\n" + "\n".join([*url_lines, url])
if len(candidate) > url_budget:
break
url_lines.append(url)
url_section = (
"\n[Source URLs]\n" + "\n".join(url_lines) if url_lines else ""
)
remaining = max_chars - len(prefix) - len(marker) - len(url_section)
head_size = max(0, int(remaining * 0.7))
tail_size = max(0, remaining - head_size)
summary = (
f"{prefix}{content[:head_size]}{marker}"
f"{content[-tail_size:] if tail_size else ''}"
f"{url_section}"
)
# A compactor must never enlarge a result, and max_chars is an actual cap.
return summary if len(summary) < len(content) else content
def compress_tool_results(
messages: list[Message],
*,
max_chars: int = _TOOL_RESULT_COMPACT_MAX_CHARS,
protect_tool_names: frozenset[str] = frozenset(),
protect_max_chars: int | None = None,
preserve_tool_result_ids: frozenset[str] = frozenset(),
) -> list[Message]:
"""Return a copy of *messages* with every large tool result condensed.
The tool-call protocol fields stay untouched, so the result is safe to
summarize or send back to a provider. Keep both ends of a result and its
URLs: command output often ends with the meaningful status, while research
output needs source links for a later re-fetch.
``protect_tool_names`` (agent-team fan-in: collect_reports / submit_report /
β¦) are left intact, or bounded by the wider ``protect_max_chars`` when one
is given. A caller whose output goes straight back to the provider MUST pass
the protect set: those same results are pinned out of
``KeepLastNToolResultsCompactor``'s spill path, so a sub-agent report cut to
a few hundred characters here is gone for good. A caller that only feeds a
summarizer can pass ``protect_max_chars`` instead, keeping the summary
request affordable while the report still arrives as more than a stub.
``preserve_tool_result_ids`` keeps specific results byte-for-byte. Tiered
compaction uses it when no spill store is available so the latest tool-call
turn β whose results have not reached the model yet β cannot be shortened.
"""
if max_chars < 200:
raise ValueError("max_chars must be at least 200")
if protect_max_chars is not None and protect_max_chars < 200:
raise ValueError("protect_max_chars must be at least 200")
id_to_name = _tool_names_by_call_id(messages) if protect_tool_names else {}
compacted: list[Message] = []
for message in messages:
clone = message.copy()
if not is_tool_msg(message):
compacted.append(clone)
continue
if str(message.get("tool_call_id") or "") in preserve_tool_result_ids:
compacted.append(clone)
continue
budget = max_chars
if id_to_name.get(message.get("tool_call_id", "")) in protect_tool_names:
if protect_max_chars is None:
compacted.append(clone)
continue
budget = protect_max_chars
content = text_of(message.get("content"))
if len(content) <= budget:
compacted.append(clone)
continue
clone["content"] = _condense(content, budget)
compacted.append(clone)
return compacted
@runtime_checkable
class MessageCompactor(Protocol):
"""Pluggable strategy for shrinking a message history.
Workflows can provide their own compactor via ``LoopConfig.compactor``
when the default middle-squash policy does not fit. Implementations
must honour two invariants:
- Any ``SystemMessage`` that sits at the head of the list stays at the
head of the returned list (agent-loop assumes this).
- The message right after any dropped ``AIMessage(tool_calls=[...])``
cannot be a bare ``ToolMessage`` β an orphan ``tool_call_id`` is a
hard HTTP 400 on Azure and other providers.
Optionally, an implementation may expose a ``last_event``
:class:`~frontier_agent.core.loop_types.CompactionEvent` describing what the
most recent ``compact`` call did. The agent loop stamps the turn and
compaction sequence onto it β which a compactor has no way to know β and
broadcasts it to ``on_compaction`` observers, which is what puts the
summary into the durable trajectory. Compactors that expose nothing are
read with ``getattr`` and simply go unreported.
"""
def compact(
self,
messages: list[Message],
keep_recent: int,
) -> list[Message]:
...
def estimate_tokens(messages: list[Message]) -> int:
"""Estimate combined token count with a small per-message overhead."""
from frontier_agent.core.runtime.loop.context_budget import estimate_tokens as _est
total = 0
for msg in messages:
total += _est(text_of(msg.get("content"))) + 4 # +4 per message overhead
return total
def compact_messages(
messages: list[Message],
keep_recent: int,
) -> list[Message]:
"""Compact a message history by summarising the middle.
Keeps system messages at the start, keeps the last ``keep_recent``
messages verbatim, and replaces the middle with a single summary
``HumanMessage`` containing short snippets of user / agent / tool
turns so the loop still has some context of what happened earlier.
"""
system_msgs: list[Message] = []
rest: list[Message] = []
for msg in messages:
if msg.get("role") == "system" and not rest:
system_msgs.append(msg)
else:
rest.append(msg)
if len(rest) <= keep_recent:
return messages # nothing to compact
# Find a clean split point: the recent window must NOT start on a
# ToolMessage β its matching AIMessage(tool_calls=[...]) would be
# split into the middle and Azure would reject the orphan
# tool_call_id with HTTP 400.
split_idx = len(rest) - keep_recent
while split_idx < len(rest) - 1 and is_tool_msg(rest[split_idx]):
split_idx += 1
middle = rest[:split_idx]
recent = rest[split_idx:]
# Keep short snippets of user / agent / tool output so the loop can
# still reason about what happened earlier. For tool calls we preserve
# name + args preview on the AIMessage side and the first URL + a
# longer result preview on the ToolMessage side, so the LLM can
# re-fetch a source whose full text was truncated away.
parts: list[str] = []
for msg in middle:
content = text_of(msg.get("content")).strip()
if content.startswith("[Compacted"):
continue
# A manifest must be dropped, never summarized: the ``content[:400]``
# cut below lands mid-path on its last entry, and Tier 2 re-attaches the
# real index from the refs it collected anyway. Recognised by its field;
# the header check is the legacy path for a history checkpointed before
# the field existed.
if msg.get("spill_refs") or SPILL_MANIFEST_HEADER in content:
continue
if msg.get("role") == "user":
if content:
parts.append(f"[User: {content[:400]}]")
elif is_assistant_msg(msg):
tcs = msg.get("tool_calls") or []
if tcs:
tc_parts: list[str] = []
for tc in tcs[:3]:
fn = tc.get("function") if isinstance(tc, dict) else None
if isinstance(fn, dict):
name = fn.get("name", "?")
args = fn.get("arguments", {})
elif isinstance(tc, dict):
name = tc.get("name", "?")
args = tc.get("args", {})
else:
name = getattr(tc, "name", "?")
args = getattr(tc, "args", {})
tc_parts.append(f"{name}({str(args)[:120]})")
tc_line = "; ".join(tc_parts)
if content:
parts.append(
f"[Agent: {content[:200]} | called: {tc_line}]"
)
else:
parts.append(f"[Agent called: {tc_line}]")
elif content:
parts.append(f"[Agent: {content[:300]}]")
elif is_tool_msg(msg):
tool_name = msg.get("name") or "tool"
urls = URL_RE.findall(content)
url_tag = f" url={urls[0]}" if urls else ""
if content:
parts.append(
f"[Tool {tool_name}{url_tag}: {content[:300]}]"
)
summary_body = "\n".join(parts[-20:]) if parts else ""
summary_text = f"[Compacted {len(middle)} earlier messages]"
if summary_body:
summary_text = f"{summary_text}\n{summary_body}"
compact_summary = user_msg(summary_text)
return [*system_msgs, compact_summary, *recent]
class StringSliceCompactor:
"""Thin adapter that wraps :func:`compact_messages` as a
:class:`MessageCompactor`.
Sync, deterministic, no LLM call. Squashes the middle of the
conversation into a single ``HumanMessage`` with short snippets per
dropped turn (see :func:`compact_messages`). The agent loop's
historical default; the SDK now defaults to
:class:`frontier_agent.core.runtime.loop.compact_llm.LLMSummaryCompactor`
instead because the structured-summary prompt preserves entities,
ruled-out candidates, and source URLs more faithfully on long runs.
Use this compactor explicitly when:
- You don't have a summarizer LLM available (offline / restricted).
- You need fully deterministic compaction for golden-file tests.
- Latency-sensitive runs where the extra LLM call is not affordable.
"""
def compact(
self,
messages: list[Message],
keep_recent: int,
) -> list[Message]:
return compact_messages(messages, keep_recent)
# Compatibility alias used by the agent-loop fallback.
DefaultMessageCompactor = StringSliceCompactor
@runtime_checkable
class CompactionPolicy(Protocol):
"""Decides *when* the agent loop should shrink its message history.
Paired with :class:`MessageCompactor` which decides *how*. The loop
calls ``should_compact(...)`` every turn (pre-LLM) and only invokes
the compactor on ``True``. Implementations must be fast β this runs
on every turn β and deterministic given the same inputs.
Workflows override this when turn-count + token-limit heuristics
don't fit: e.g. "only compact when the next message would exceed
90% of the provider's window" or "never compact, I wrote my own
retention inside the compactor".
"""
def should_compact(
self,
turn: int,
messages: list[Message],
estimated_tokens: int,
) -> bool:
...
class DefaultCompactionPolicy:
"""Default policy: compact when the turn or token threshold trips.
Mirrors the inline check that lived in ``agent_loop.py`` before this
Protocol was extracted β ``turn > compact_after_turns`` OR
``estimated_tokens > context_token_limit``.
"""
def __init__(
self,
compact_after_turns: int,
context_token_limit: int,
) -> None:
self._compact_after_turns = compact_after_turns
self._context_token_limit = context_token_limit
def should_compact(
self,
turn: int,
messages: list[Message],
estimated_tokens: int,
) -> bool:
return (
turn > self._compact_after_turns
or estimated_tokens > self._context_token_limit
)
class KeepLastNToolResultsCompactor:
"""Replace older ``ToolMessage`` bodies with a short placeholder.
Keeps the last ``keep_tool_result`` tool results verbatim and replaces
the content of every earlier one with :data:`OMITTED_TOOL_RESULT_PLACEHOLDER`.
``SystemMessage``, ``HumanMessage``, and every ``AIMessage`` (including
its thinking trace) are left intact, so the model retains its full
chain of reasoning and tool-call metadata while dropping the bulk of
old tool-result bodies (the dominant context cost in long ReAct runs).
Idempotent: already-placeheld messages are detected by content match
and left alone, so this is safe to invoke on every turn.
``keep_tool_result == -1`` disables filtering entirely.
Caveat: only ``ToolMessage`` content is redacted. Workflows that
inject large content as ``HumanMessage`` (e.g. an observer that
splices fan-in reports between turns) bypass this compactor; pair
with a different strategy or route the content through a tool so
it lands as ``ToolMessage``.
"""
def __init__(
self,
keep_tool_result: int,
protect_tool_names: frozenset[str] = frozenset(),
spill: Callable[[str, str], str | None] | None = None,
) -> None:
if keep_tool_result < -1:
raise ValueError(
f"keep_tool_result must be >= -1 (got {keep_tool_result})"
)
self._keep = keep_tool_result
# Tool names whose results are NEVER blanked regardless of age (e.g.
# agent-team fan-in: collect_reports / assign_task / submit_report).
# Resolved by tool_call_id β the requesting AIMessage's tool_calls,
# because a ``ToolMessage`` carries no name. Empty (default) = blank by
# age only, so existing callers are unaffected.
self._protect = frozenset(protect_tool_names)
self._spill = spill
def compact(
self,
messages: list[Message],
keep_recent: int,
) -> list[Message]:
if self._keep == -1:
return messages
tool_indices = [
i for i, m in enumerate(messages) if is_tool_msg(m)
]
if not tool_indices:
return messages
keep_count = min(self._keep, len(tool_indices))
keep_set = (
set(tool_indices[-keep_count:]) if keep_count > 0 else set()
)
if len(keep_set) == len(tool_indices):
return messages
id_to_name = (
_tool_names_by_call_id(messages)
if self._protect or self._spill is not None
else {}
)
out: list[Message] = []
for idx, msg in enumerate(messages):
if not is_tool_msg(msg) or idx in keep_set:
out.append(msg)
continue
if self._protect and id_to_name.get(msg.get("tool_call_id", "")) in self._protect:
out.append(msg) # protected fan-in result β never blank
continue
content = text_of(msg.get("content"))
if content.startswith(OMITTED_TOOL_RESULT_PLACEHOLDER):
out.append(msg)
continue
placeholder = OMITTED_TOOL_RESULT_PLACEHOLDER
spill_path: str | None = None
if self._spill is not None:
tool_name = id_to_name.get(msg.get("tool_call_id", ""), "tool")
try:
spill_path = self._spill(tool_name, content)
except Exception:
spill_path = None
if spill_path:
placeholder += f"\n[Full text] {spill_path}"
replacement = tool_msg(placeholder, msg.get("tool_call_id", ""))
if spill_path:
# The text is for the model; this is for us. ``TieredCompactor``
# collects refs from the field, so nothing has to recognise a
# path by its shape.
replacement["spill_refs"] = [spill_path]
out.append(replacement)
return out
|