Files
wehub-resource-sync 26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:30 +08:00

299 lines
10 KiB
Python

"""Pure compaction logic for OpenAI-format message lists.
This module is deliberately free of I/O and host coupling so it can be unit
tested in isolation. The engine (``engine.py``) wires the offload/recall side
effects around :func:`plan_compaction`.
Invariants guaranteed by construction:
* an ``assistant`` message carrying ``tool_calls`` is never separated from its
following ``tool`` result messages (they form one atomic block);
* leading and inline ``system``/``developer`` messages are preserved verbatim
(lifted out of the compacted region), so instructions are never dropped;
* output is deterministic for a given input (prompt-cache friendly, no
timestamps/counters per AGENTS.md #498).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Sequence, Tuple
from . import tokens as _tokens
Message = Dict[str, Any]
TokenCounter = Callable[[Sequence[Message]], int]
PROTECTED_ROLES = ("system", "developer")
SUMMARY_MARKER = "[lean-ctx] compacted-context"
def _role(msg: Message) -> str:
role = msg.get("role")
return role if isinstance(role, str) else ""
def _has_tool_calls(msg: Message) -> bool:
return bool(msg.get("tool_calls"))
def atomic_blocks(messages: Sequence[Message]) -> List[Tuple[int, int]]:
"""Group messages into atomic ``[start, end)`` blocks.
An ``assistant`` message with ``tool_calls`` plus its trailing ``tool``
results is one block. A stray leading ``tool`` message is attached to the
previous block so a block never *starts* with a tool result.
"""
blocks: List[Tuple[int, int]] = []
i = 0
n = len(messages)
while i < n:
msg = messages[i]
if _role(msg) == "tool" and blocks:
# Attach orphan tool result to the previous block.
start, _ = blocks[-1]
blocks[-1] = (start, i + 1)
i += 1
continue
if _role(msg) == "assistant" and _has_tool_calls(msg):
j = i + 1
while j < n and _role(messages[j]) == "tool":
j += 1
blocks.append((i, j))
i = j
else:
blocks.append((i, i + 1))
i += 1
return blocks
@dataclass
class CompactionPlan:
"""Result of planning a compaction (pure data, no side effects applied)."""
head: List[Message] = field(default_factory=list) # leading system/developer
lifted: List[Message] = field(default_factory=list) # system/developer rescued from older
to_summarize: List[Message] = field(default_factory=list) # offloaded + summarized
tail: List[Message] = field(default_factory=list) # verbatim fresh tail
@property
def nothing_to_do(self) -> bool:
return not self.to_summarize
def plan_compaction(
messages: Sequence[Message],
*,
protect_tokens: int,
protect_min_messages: int,
token_counter: TokenCounter | None = None,
) -> CompactionPlan:
"""Split ``messages`` into head / lifted / to_summarize / tail.
``protect_tokens`` and ``protect_min_messages`` bound the fresh tail kept
verbatim. The split always lands on an atomic-block boundary.
"""
count = token_counter or _tokens.count_messages_tokens
msgs = list(messages)
n = len(msgs)
if n == 0:
return CompactionPlan()
# 1) Leading contiguous system/developer preamble.
head_end = 0
while head_end < n and _role(msgs[head_end]) in PROTECTED_ROLES:
head_end += 1
head = msgs[:head_end]
body = msgs[head_end:]
if not body:
return CompactionPlan(head=head)
# 2) Atomic blocks over the body; choose trailing blocks for the tail.
blocks = atomic_blocks(body)
tail_start_block = len(blocks)
tail_tokens = 0
tail_msg_count = 0
for bi in range(len(blocks) - 1, -1, -1):
start, end = blocks[bi]
block_msgs = body[start:end]
# Always include at least the most recent block; then stop once both
# the token budget and the minimum message count are satisfied.
if tail_start_block != len(blocks) and (
tail_tokens >= protect_tokens and tail_msg_count >= protect_min_messages
):
break
tail_start_block = bi
tail_tokens += count(block_msgs)
tail_msg_count += len(block_msgs)
tail_msg_index = blocks[tail_start_block][0] if tail_start_block < len(blocks) else len(body)
older = body[:tail_msg_index]
tail = body[tail_msg_index:]
# 3) Rescue inline system/developer messages from the older region.
lifted = [m for m in older if _role(m) in PROTECTED_ROLES]
to_summarize = [m for m in older if _role(m) not in PROTECTED_ROLES]
return CompactionPlan(head=head, lifted=lifted, to_summarize=to_summarize, tail=tail)
def _snippet(text: str, limit: int = 160) -> str:
text = " ".join(text.split())
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + "…"
def build_summary_text(
to_summarize: Sequence[Message],
*,
focus_topic: str | None = None,
recall_hint: str = "",
max_user_snippets: int = 24,
) -> str:
"""Build a deterministic digest of the offloaded messages.
No LLM call and no time/random input — the same messages always produce the
same text. The real lean-ctx consolidation summary arrives in Phase 2 via
the core ``ctx_transcript_compact`` tool.
"""
role_counts: Dict[str, int] = {}
tool_names: List[str] = []
user_snippets: List[str] = []
tool_calls = 0
for msg in to_summarize:
role = _role(msg) or "unknown"
role_counts[role] = role_counts.get(role, 0) + 1
for tc in msg.get("tool_calls") or []:
tool_calls += 1
if isinstance(tc, dict):
fn = tc.get("function", {}) or {}
name = fn.get("name")
if isinstance(name, str) and name and name not in tool_names:
tool_names.append(name)
if role == "user":
content = _tokens.normalize_content_value(msg.get("content"))
if content.strip():
user_snippets.append(_snippet(content))
approx_tokens = _tokens.count_messages_tokens(list(to_summarize))
lines: List[str] = []
lines.append(f"## {SUMMARY_MARKER}")
lines.append(
f"{len(to_summarize)} earlier messages (~{approx_tokens} tokens) were "
"offloaded to lean-ctx and replaced by this summary. Full detail is "
"recoverable with the tools listed below."
)
if focus_topic:
lines.append(f"Focus retained: {focus_topic}.")
if user_snippets:
lines.append("")
lines.append("User intents (chronological):")
for snip in user_snippets[:max_user_snippets]:
lines.append(f"- {snip}")
extra = len(user_snippets) - max_user_snippets
if extra > 0:
lines.append(f"- … (+{extra} more user messages)")
activity = (
f"{role_counts.get('assistant', 0)} assistant turns, "
f"{role_counts.get('tool', 0)} tool results, {tool_calls} tool calls"
)
if tool_names:
activity += f" across: {', '.join(sorted(tool_names))}"
lines.append("")
lines.append(f"Activity: {activity}.")
if recall_hint:
lines.append("")
lines.append(recall_hint)
return "\n".join(lines)
def build_summary_message(
to_summarize: Sequence[Message],
*,
focus_topic: str | None = None,
recall_hint: str = "",
) -> Message:
"""Build the single ``system`` message that replaces the offloaded turns."""
return {
"role": "system",
"content": build_summary_text(
to_summarize, focus_topic=focus_topic, recall_hint=recall_hint
),
}
def assemble(plan: CompactionPlan, summary_message: Message | None) -> List[Message]:
"""Assemble the final message list from a plan and optional summary block."""
out: List[Message] = []
out.extend(plan.head)
out.extend(plan.lifted)
if summary_message is not None and plan.to_summarize:
out.append(summary_message)
out.extend(plan.tail)
return out
def serialize_transcript(messages: Sequence[Message], *, max_chars: int = 8_000) -> str:
"""Render messages to a plain-text transcript for durable offload.
Bounded by ``max_chars`` keeping the head and tail (the start frames intent,
the end frames recent state). Deterministic for a given input.
"""
lines: List[str] = []
for msg in messages:
role = _role(msg) or "unknown"
content = _tokens.normalize_content_value(msg.get("content")).strip()
if content:
lines.append(f"{role}: {content}")
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict):
fn = tc.get("function", {}) or {}
name = fn.get("name", "")
args = fn.get("arguments", "")
lines.append(f"{role} -> tool_call {name}({args})")
text = "\n".join(lines)
if len(text) <= max_chars:
return text
half = max_chars // 2
omitted = len(text) - 2 * half
return f"{text[:half]}\n… [{omitted} chars omitted] …\n{text[-half:]}"
def tool_pairing_errors(messages: Sequence[Message]) -> List[str]:
"""Return a list of tool_call/tool_result pairing violations (empty == OK).
Used by the test-suite to assert the hard OpenAI-sequence invariant after
compaction.
"""
errors: List[str] = []
open_ids: set = set()
expecting_tool_results = False
for idx, msg in enumerate(messages):
role = _role(msg)
if role == "assistant" and _has_tool_calls(msg):
open_ids = set()
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict) and tc.get("id"):
open_ids.add(tc["id"])
expecting_tool_results = bool(open_ids)
elif role == "tool":
tcid = msg.get("tool_call_id")
if not expecting_tool_results:
errors.append(f"orphan tool result at index {idx} (no preceding assistant tool_calls)")
elif tcid is not None and open_ids and tcid not in open_ids:
errors.append(f"tool result at index {idx} references unknown tool_call_id {tcid!r}")
else:
open_ids.discard(tcid)
if not open_ids:
expecting_tool_results = False
else:
expecting_tool_results = False
open_ids = set()
return errors