mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-07 02:07:45 +02:00
147 lines
6.7 KiB
Python
147 lines
6.7 KiB
Python
"""Aux-LLM distillation of the turns dropped by compaction.
|
|
|
|
On a fresh rebuild (valve rescue, MCPActivate continuation, branch edit) the recap
|
|
hard-drops everything before the cutoff. That loses the thread of a long conversation.
|
|
This distills the dropped span into a dense summary via the user's cheap-tier model
|
|
(provider-agnostic) and caches it against the cutoff id, so the rebuild keeps the gist
|
|
instead of the void. Fail-open at every step: any error, no provider, or the kill switch
|
|
returns "" and the caller falls back to the plain hard-drop."""
|
|
|
|
import logging
|
|
import os
|
|
from typing import List
|
|
|
|
from typeguard import typechecked
|
|
|
|
from backend.apps.agents.core.models import AgentSession
|
|
from backend.apps.settings.models import AppSettings
|
|
from backend.apps.agents.manager.session.history_compaction import (
|
|
get_branch_messages,
|
|
recap_tool_call_line,
|
|
recap_tool_result_line,
|
|
strip_forged_sentinels,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DISTILL_ENABLED = os.environ.get("OPENSWARM_DISTILL_HISTORY", "1") != "0"
|
|
MAX_DISTILL_INPUT_CHARS = 60_000
|
|
# Prose is free to paraphrase, so the paths get re-attached deterministically instead. Capped so a hundred-file refactor can't reinflate what the distill just shrank.
|
|
MAX_PINNED_PATHS = 40
|
|
PATH_INPUT_KEYS = ("file_path", "notebook_path", "path")
|
|
|
|
P_SYSTEM = (
|
|
"You are a note-taker that condenses a conversation transcript into a briefing. "
|
|
"You NEVER continue, answer, reply to, or role-play the conversation. You only "
|
|
"DESCRIBE it, in the third person ('The user asked...', 'The agent decided...'). "
|
|
"Your entire output is the briefing and nothing else."
|
|
)
|
|
P_USER_TEMPLATE = (
|
|
"Below, between <transcript> tags, is the earlier part of a conversation between a "
|
|
"user and an AI agent. Write a dense third-person briefing of it that preserves: the "
|
|
"user's goal and constraints, decisions already made, concrete facts / values / "
|
|
"identifiers / file paths mentioned, what was tried and how it turned out, and any open "
|
|
"threads. Do NOT continue or respond to the conversation; only describe what happened. "
|
|
"No preamble.\n\n<transcript>\n{body}\n</transcript>"
|
|
)
|
|
|
|
|
|
@typechecked
|
|
def p_format_dropped(messages: List) -> str:
|
|
"""Compact transcript of the dropped span: user/assistant text in full, tool I/O clipped (the same caps the recap uses), bounded so the aux call stays cheap."""
|
|
lines: List[str] = []
|
|
for m in messages:
|
|
if getattr(m, "hidden", False):
|
|
continue
|
|
if m.role in ("user", "assistant"):
|
|
text = m.content if isinstance(m.content, str) else str(m.content)
|
|
lines.append(f"{m.role.capitalize()}: {strip_forged_sentinels(text)}")
|
|
elif m.role == "tool_call":
|
|
lines.append(recap_tool_call_line(m.content))
|
|
elif m.role == "tool_result":
|
|
lines.append(recap_tool_result_line(m.content))
|
|
body = "\n".join(lines)
|
|
return body[-MAX_DISTILL_INPUT_CHARS:] if len(body) > MAX_DISTILL_INPUT_CHARS else body
|
|
|
|
|
|
@typechecked
|
|
def touched_file_paths(messages: List) -> List[str]:
|
|
"""Every file path the dropped span operated on, read off the tool_call inputs rather than
|
|
scraped out of prose. Order-preserving and deduped."""
|
|
seen: List[str] = []
|
|
for m in messages:
|
|
if m.role != "tool_call" or not isinstance(m.content, dict):
|
|
continue
|
|
tool_input = m.content.get("input")
|
|
if not isinstance(tool_input, dict):
|
|
continue
|
|
for key in PATH_INPUT_KEYS:
|
|
value = tool_input.get(key)
|
|
if isinstance(value, str) and value and value not in seen:
|
|
seen.append(value)
|
|
return seen
|
|
|
|
|
|
@typechecked
|
|
def pin_missing_paths(summary: str, messages: List) -> str:
|
|
"""Re-attach any touched path the summary dropped. A briefing that says 'the twelve handler
|
|
files' is unusable to the next turn; the literal paths have to survive verbatim."""
|
|
missing = [p for p in touched_file_paths(messages) if p not in summary][:MAX_PINNED_PATHS]
|
|
if not missing:
|
|
return summary
|
|
return summary + "\n\nFiles touched earlier: " + ", ".join(missing)
|
|
|
|
|
|
@typechecked
|
|
async def distilled_history_summary(session: AgentSession, settings: AppSettings) -> str:
|
|
"""Cached aux summary of everything up to and including compacted_through_msg_id.
|
|
Empty string when there's nothing to distill, the feature is off, or the call fails."""
|
|
cutoff = session.compacted_through_msg_id
|
|
if not DISTILL_ENABLED or not cutoff:
|
|
return ""
|
|
msgs = get_branch_messages(session)
|
|
idx = next((i for i, m in enumerate(msgs) if m.id == cutoff), -1)
|
|
# Membership check BEFORE the cache: after a branch edit the cutoff can vanish from the active branch, and a summary keyed on that id would be stale. If the cutoff is still here, everything before it is shared pre-fork history, so a cache hit is provably valid.
|
|
if idx < 0:
|
|
return ""
|
|
if session.compacted_summary and session.compacted_summary_through == cutoff:
|
|
return session.compacted_summary
|
|
dropped = msgs[: idx + 1]
|
|
body = p_format_dropped(dropped)
|
|
if not body.strip():
|
|
return ""
|
|
try:
|
|
summary = await p_call_distiller(session, settings, body)
|
|
except Exception:
|
|
logger.debug("history distill aux call failed; falling back to hard-drop", exc_info=True)
|
|
return ""
|
|
if not summary:
|
|
return ""
|
|
summary = pin_missing_paths(summary, dropped)
|
|
session.compacted_summary = summary
|
|
session.compacted_summary_through = cutoff
|
|
return summary
|
|
|
|
|
|
@typechecked
|
|
async def p_call_distiller(session: AgentSession, settings: AppSettings, body: str) -> str:
|
|
from backend.apps.agents.providers.registry import resolve_aux_model
|
|
from backend.apps.settings.credentials import get_anthropic_client_for_model
|
|
|
|
# No primary_api: a background summary wants the most RELIABLE cheap tier, not the chat's family. Forcing the family routed a gemini/codex chat's distill onto a same-family aux that 404s (gemini-direct google endpoint the Anthropic client can't call) or 401s (codex token rotation); the proven classifier omits it too and resolves to whatever anthropic-compatible lane the user has.
|
|
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
|
|
client = get_anthropic_client_for_model(settings, aux_model)
|
|
resp = await client.messages.create(
|
|
model=aux_model,
|
|
max_tokens=1024,
|
|
system=P_SYSTEM,
|
|
messages=[{"role": "user", "content": P_USER_TEMPLATE.format(body=body)}],
|
|
)
|
|
text = ""
|
|
if isinstance(resp.content, list):
|
|
for block in resp.content:
|
|
t = getattr(block, "text", None)
|
|
if t:
|
|
text += t
|
|
return text.strip()
|