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