-
Notifications
You must be signed in to change notification settings - Fork 16
feat: tool truncation + auto-compaction #147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 28 commits
Commits
Show all changes
30 commits
Select commit
Hold shift + click to select a range
9f64353
recover from context overflow
mikasenghaas b01108e
discover compaction thresholds
mikasenghaas d5bdb25
nest compaction configuration
mikasenghaas 235ff4c
simplify compaction flow
mikasenghaas 1e45450
clarify compaction retry
mikasenghaas f452d52
flatten compaction policy
mikasenghaas da9f4a4
parse the models listing as a mapping
mikasenghaas ac8fdb0
resample checkpoint summaries
mikasenghaas b1b4140
forbid tool calls in the checkpoint prompt
mikasenghaas f5c14aa
simplify checkpoint to one attempt
mikasenghaas f1c51fb
Merge remote-tracking branch 'origin/main' into feat/context-compaction
mikasenghaas 4fd3fa2
reserve fixed headroom and truncate tool output
mikasenghaas 3b97900
attribute overflow markers to their providers
mikasenghaas 4bb5f48
catch byte-size overflow too
mikasenghaas b928097
compact from the last good state
mikasenghaas 31500ad
Merge remote-tracking branch 'origin/main' into feat/context-compaction
mikasenghaas 0e97b0b
discover the context window via models.list
mikasenghaas be1ab18
accept a summary from the reasoning channel
mikasenghaas 3aa4f3b
release the summary claim before a resample
mikasenghaas b2a5817
read the reasoning channel from model extras
mikasenghaas a1eca3e
tolerate messages without model extras
mikasenghaas 8e83ff8
summaries use only non-reasoning output
mikasenghaas 1ee1cba
floor the checkpoint fallback at the initial conversation
mikasenghaas a4f1253
end the run cleanly when the retry still overflows
mikasenghaas 391f086
only usage-verified states become checkpoint fallbacks
mikasenghaas 4904186
restore the actionable checkpoint prompt
mikasenghaas 0f9f6de
fix checkpoint-fallback staleness and discovery caching
mikasenghaas 7f6a408
end cleanly when a compaction floor still overflows
mikasenghaas f300615
restore the compacted flag on prompt rollback
mikasenghaas 249eb5c
merge main into context compaction
mikasenghaas File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| """Context checkpoint helpers.""" | ||
|
|
||
| from collections.abc import Mapping | ||
| from typing import Any | ||
|
|
||
| from openai import APIError, APIStatusError, AsyncOpenAI | ||
|
|
||
| CHECKPOINT_PROMPT = """You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary another LLM can ACT on immediately to resume the task. | ||
|
|
||
| It MUST contain, as fenced code blocks (not prose): | ||
| - The exact shell/test command(s) to reproduce and verify — copy-pasteable, with the real path and test filter | ||
| - Any edit still to apply, as the concrete `await edit(path=..., old_str=..., new_str=...)` call | ||
|
|
||
| Then: | ||
| - A NUMBERED list of remaining next steps | ||
| - Current progress, key decisions, and constraints | ||
|
|
||
| Be concise and concrete: prefer runnable commands over descriptions. | ||
|
|
||
| Reply with the summary as plain text. Do not call any tools - summarize from the conversation as it stands.""" | ||
|
|
||
| REPL_NOTE = ( | ||
| "\n\n" | ||
| "Note: the IPython kernel stays running across this compaction. " | ||
| "All variables, imports, and in-memory data are preserved. " | ||
| "Mention important variable names and what they contain so the " | ||
| "next LLM knows what's available." | ||
| ) | ||
|
|
||
| SUMMARY_FRAMING = """Another language model started to solve this problem and produced \ | ||
| a summary of its thinking process. You also have access to the state of the tools that \ | ||
| were used by that language model. Use this to build on the work \ | ||
| that has already been done and avoid duplicating work. Here is \ | ||
| the summary produced by the other language model, use the \ | ||
| information in this summary to assist with your own analysis:""" | ||
|
|
||
| RESERVE_TOKENS = 16_384 | ||
| """Compact when this many tokens remain below the model context window.""" | ||
|
|
||
| COMPACTION_ATTEMPTS = 3 | ||
| """Checkpoint attempts before compaction fails: a rejected request falls back to the | ||
| last good snapshot; an empty or tool-calling reply is resampled.""" | ||
|
|
||
| TOOL_OUTPUT_MAX_BYTES = 20_000 | ||
| """Middle-out truncation budget for one tool result before it enters the conversation.""" | ||
|
|
||
| _CONTEXT_FIELDS = ( | ||
| "max_model_len", | ||
| "context_length", | ||
| "context_window", | ||
| "max_context_length", | ||
| ) | ||
| _OVERFLOW_MARKERS = ( | ||
| # OpenAI error code "context_length_exceeded"; OpenRouter relays the raw body. | ||
| "context_length_exceeded", | ||
| # OpenAI Responses/Completions: "Your input exceeds the context window of this model". | ||
| "exceeds the context window", | ||
| # OpenAI chat: "Input tokens exceed the configured limit of N tokens. Please reduce | ||
| # the length of the messages."; Groq words it the same way. | ||
| "reduce the length of the messages", | ||
| # vLLM: "This model's maximum context length is N tokens"; the renderers pre-flight: | ||
| # "Prompt length (N) exceeds maximum context length (M)"; Mistral uses the same words. | ||
| "maximum context length", | ||
| # Anthropic: "prompt is too long: N tokens > M maximum". | ||
| "prompt is too long", | ||
| # Anthropic byte-size overflow: HTTP 413 {"type": "request_too_large"}. | ||
| "request_too_large", | ||
| # HTTP proxies reject an oversized body with 413 "Request Entity Too Large". | ||
| "request entity too large", | ||
| # Google: "The input token count (N) exceeds the maximum number of tokens allowed (M)". | ||
| "exceeds the maximum number of tokens", | ||
| # xAI: "This model's maximum prompt length is N but the request contains M tokens". | ||
| "maximum prompt length is", | ||
| ) | ||
| _window_cache: dict[tuple[str, str], int | None] = {} | ||
|
|
||
|
|
||
| class CompactionFailed(Exception): | ||
| """Every checkpoint attempt failed - the caller ends the run cleanly instead.""" | ||
|
|
||
|
|
||
| def is_context_overflow(error: APIStatusError) -> bool: | ||
| details = f"{error} {error.body or ''}" | ||
| # An overflow is deterministic: a 400, or a 413 for a byte-size cap. | ||
| return error.status_code in (400, 413) and any( | ||
| marker in details.casefold() for marker in _OVERFLOW_MARKERS | ||
| ) | ||
|
|
||
|
|
||
| def default_threshold(context_window: int) -> int: | ||
| """Leave a fixed reserve below the window; small windows keep at least half.""" | ||
| return max(context_window - RESERVE_TOKENS, context_window // 2) | ||
|
|
||
|
|
||
| def _model_context_window(payload: Mapping[str, Any], model: str) -> int | None: | ||
| card = next( | ||
| ( | ||
| item | ||
| for item in payload.get("data") or [] | ||
| if isinstance(item, Mapping) and item.get("id") == model | ||
| ), | ||
| None, | ||
| ) | ||
| if card is None: | ||
| return None | ||
| for field in _CONTEXT_FIELDS: | ||
| value = card.get(field) | ||
| if isinstance(value, int) and not isinstance(value, bool) and value > 0: | ||
| return value | ||
| return None | ||
|
|
||
|
|
||
| async def discover_threshold(client: AsyncOpenAI, model: str) -> int | None: | ||
| key = (str(client.base_url), model) | ||
| if key not in _window_cache: | ||
| try: | ||
| # `models.list()` keeps provider extensions in each card's `model_extra`; | ||
| # a raw `cast_to` parse breaks on one Python version or another. | ||
| page = await client.models.list() | ||
| payload = { | ||
| "data": [ | ||
| {"id": card.id, **(card.model_extra or {})} for card in page.data | ||
| ] | ||
| } | ||
| except (APIError, AttributeError): | ||
| # A transient listing failure must not disable compaction for the | ||
| # rest of the process - leave the cache empty so the next engine retries. | ||
| return None | ||
| _window_cache[key] = _model_context_window(payload, model) | ||
| window = _window_cache[key] | ||
| return default_threshold(window) if window is not None else None | ||
|
|
||
|
|
||
| def truncate_tool_output(text: str) -> str: | ||
| """Keep the head and tail of an oversized tool result and say what was cut.""" | ||
| data = text.encode("utf-8") | ||
| if len(data) <= TOOL_OUTPUT_MAX_BYTES: | ||
| return text | ||
| keep = TOOL_OUTPUT_MAX_BYTES // 2 | ||
| head = data[:keep].decode("utf-8", errors="ignore") | ||
| tail = data[-keep:].decode("utf-8", errors="ignore") | ||
| return ( | ||
| f"Warning: truncated output (original token count: {estimated_tokens(text)})\n" | ||
| f"Total output lines: {text.count(chr(10)) + 1}\n\n" | ||
| f"{head}\n[... {len(data) - 2 * keep} bytes truncated ...]\n{tail}" | ||
| ) | ||
|
|
||
|
|
||
| def estimated_tokens(chars: str) -> int: | ||
| """Rough token count at four characters per token.""" | ||
| return (len(chars) + 3) // 4 | ||
|
|
||
|
|
||
| def compactable(messages: list[dict]) -> bool: | ||
| """Whether compaction can reclaim anything - some history beyond the task exists.""" | ||
| first_user = next( | ||
| (i for i, m in enumerate(messages) if m.get("role") == "user"), None | ||
| ) | ||
| return any( | ||
| m.get("role") != "system" and i != first_user for i, m in enumerate(messages) | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.