fix: fire auto-compact inside Runner.run, not just at outer-loop level

Previously CAI_SUPPORT_INTERVAL only checked at the end of each outer
while-loop iteration (once per user input). In agentic sessions the agent
makes many successive tool calls inside a single Runner.run invocation,
so the check would only fire after the runner returned — too late, or
never if the runner was still running.

Fix:
- Add ContextCompactedError exception class.
- Add a count-based trigger at the top of _auto_compact_if_needed (which
  fires on EVERY LLM API call, inside both get_response and stream_response).
  When assistant-message count >= CAI_SUPPORT_INTERVAL:
    1. Set the compact model to CAI_SUPPORT_MODEL temporarily.
    2. Summarise via _ai_summarize_history (awaited in-situ, no asyncio.run).
    3. Store summary in COMPACTED_SUMMARIES so get_system_prompt picks it
       up on the next turn without needing agent reload.
    4. Clear message_history + reset CAI_CONTEXT_USAGE.
    5. Raise ContextCompactedError to abort the current runner invocation.
- cli.py catches ContextCompactedError in both streaming and non-streaming
  runner call sites:
    - Sets _post_compact_input = _last_user_input so the task is replayed.
    - Re-syncs the local agent reference via AGENT_MANAGER.
    - Continues the outer while-loop, restarting with a clean context window.

The existing outer-loop CLI check (counting assistant messages in history
after the runner finishes) is kept as a belt-and-suspenders fallback.
This commit is contained in:
giveen 2026-04-02 16:09:22 -06:00
parent 85324782e4
commit 6ee2f0d66b
2 changed files with 94 additions and 1 deletions

View File

@ -313,6 +313,7 @@ from cai.sdk.agents.exceptions import OutputGuardrailTripwireTriggered, InputGua
from cai.sdk.agents.models.openai_chatcompletions import (
get_agent_message_history,
get_all_agent_histories,
ContextCompactedError,
)
# Import handled where needed to avoid circular imports
from cai.sdk.agents.run_to_jsonl import get_session_recorder
@ -1576,6 +1577,9 @@ def run_cai_cli(
pass
raise e
except ContextCompactedError:
# Propagate so the outer try block can handle the restart.
raise
except Exception as e:
# Clean up on any other exception
if stream_iterator is not None:
@ -1603,6 +1607,17 @@ def run_cai_cli(
try:
asyncio.run(process_streamed_response(agent, conversation_input))
except ContextCompactedError:
# Auto-compact fired mid-runner; restart with fresh context.
_post_compact_input = _last_user_input or "Continue the current task."
from cai.sdk.agents.simple_agent_manager import AGENT_MANAGER as _AM
_reloaded = _AM.get_active_agent()
if _reloaded is not None:
agent = _reloaded
console.print(
"[bold green]✓ Context window reset — resuming task[/bold green]\n"
)
continue
except OutputGuardrailTripwireTriggered as e:
# Display a user-friendly warning instead of crashing (streaming mode)
guardrail_name = e.guardrail_result.guardrail.get_name()
@ -1662,6 +1677,17 @@ def run_cai_cli(
# Use non-streamed response
try:
response = asyncio.run(Runner.run(agent, conversation_input))
except ContextCompactedError:
# Auto-compact fired mid-runner; restart with fresh context.
_post_compact_input = _last_user_input or "Continue the current task."
from cai.sdk.agents.simple_agent_manager import AGENT_MANAGER as _AM
_reloaded = _AM.get_active_agent()
if _reloaded is not None:
agent = _reloaded
console.print(
"[bold green]✓ Context window reset — resuming task[/bold green]\n"
)
continue
except InputGuardrailTripwireTriggered as e:
# Display a user-friendly warning for input guardrails
reason = "Potential security threat detected in input"

View File

@ -363,6 +363,13 @@ def count_tokens_with_tiktoken(text_or_messages):
return 0, 0
class ContextCompactedError(Exception):
"""Raised inside get_response/stream_response when a CAI_SUPPORT_INTERVAL-based
auto-compact fires mid-runner. The outer CLI loop catches this, sets
_post_compact_input, and restarts the runner with a clean context window."""
pass
class OpenAIChatCompletionsModel(Model):
"""OpenAI Chat Completions Model"""
@ -3475,7 +3482,67 @@ class OpenAIChatCompletionsModel(Model):
# Check if auto-compaction is disabled
if os.getenv("CAI_AUTO_COMPACT", "true").lower() == "false":
return input, system_instructions, False
# --- CAI_SUPPORT_INTERVAL count-based trigger ---
# This fires on EVERY API call (not just at the outer CLI-loop level), so it correctly
# handles agentic sessions where the agent makes many tool calls inside one Runner.run.
_support_model = os.getenv("CAI_SUPPORT_MODEL")
_support_interval_raw = os.getenv("CAI_SUPPORT_INTERVAL")
if _support_model and _support_interval_raw:
try:
_support_interval = int(_support_interval_raw)
if _support_interval > 0:
_asst_count = sum(
1 for m in self.message_history
if isinstance(m, dict) and m.get("role") == "assistant"
)
if _asst_count >= _support_interval:
from rich.console import Console as _Console
_console = _Console()
_console.print(
f"\n[bold yellow]⟳ Auto-compact: {_asst_count} LLM responses "
f"(threshold {_support_interval}) — summarising with "
f"{_support_model}[/bold yellow]"
)
try:
from cai.repl.commands.memory import (
MEMORY_COMMAND_INSTANCE,
COMPACTED_SUMMARIES,
APPLIED_MEMORY_IDS,
)
from cai.repl.commands.compact import COMPACT_COMMAND_INSTANCE
_orig_compact = COMPACT_COMMAND_INSTANCE.compact_model
COMPACT_COMMAND_INSTANCE.compact_model = _support_model
try:
_summary = await MEMORY_COMMAND_INSTANCE._ai_summarize_history(
self.agent_name
)
finally:
COMPACT_COMMAND_INSTANCE.compact_model = _orig_compact
if _summary:
if self.agent_name not in COMPACTED_SUMMARIES:
COMPACTED_SUMMARIES[self.agent_name] = []
APPLIED_MEMORY_IDS[self.agent_name] = []
COMPACTED_SUMMARIES[self.agent_name] = [_summary]
self.message_history.clear()
os.environ["CAI_CONTEXT_USAGE"] = "0.0"
_console.print(
"[bold green]✓ Memory summary applied — "
"context window reset — restarting task[/bold green]\n"
)
except Exception as _ce:
_console.print(f"[red]Auto-compact error: {_ce}[/red]")
# Always abort the current runner invocation so the outer loop
# can restart with our freshly cleared context.
raise ContextCompactedError(
f"Context compacted after {_asst_count} LLM responses "
f"(threshold {_support_interval})"
)
except ContextCompactedError:
raise # propagate to the outer runner / CLI loop
except (ValueError, Exception):
pass # malformed interval — ignore silently
max_tokens = self._get_model_max_tokens(str(self.model))
threshold_percent = float(os.getenv("CAI_AUTO_COMPACT_THRESHOLD", "0.8"))
threshold = max_tokens * threshold_percent