fix: prevent post-compaction repeat work

Three root causes identified and fixed:

1. _format_history_for_summary dropped ALL tool outputs >500 chars with
   '[Long output truncated]', meaning nmap/gobuster/curl results — the
   exact evidence the summary model needs to write the 'Exhausted
   Approaches' section — were silently discarded. Increased limit to
   2000 chars (first chars of each output), bumped the message cap from
   50 to 200 blocks, and fixed the assistant tool-call extractor which
   only handled object-style tool_calls (not the dict-style format used
   in message_history), causing every command ever run to disappear.

2. _post_compact_input was set to the raw original user task (e.g.
   'hack the box machine Cap'). That becomes the last message the LLM
   reads, overriding the memory acknowledgement and making the agent
   treat it as a brand-new task. Now injects an explicit anti-repetition
   instruction alongside the original task text.

3. (Previous fix) Summary prompt now includes §9 Exhausted Approaches
   and §10 Recommended Next Steps — this only works if the summary model
   actually sees the scan/tool data, which fix #1 now guarantees.
This commit is contained in:
giveen 2026-04-02 17:23:13 -06:00
parent 476efb3994
commit 6546d8cbde
2 changed files with 62 additions and 23 deletions

View File

@ -1613,7 +1613,16 @@ def run_cai_cli(
asyncio.run(process_streamed_response(agent, conversation_input)) asyncio.run(process_streamed_response(agent, conversation_input))
except ContextCompactedError: except ContextCompactedError:
# Auto-compact fired mid-runner; restart with fresh context. # Auto-compact fired mid-runner; restart with fresh context.
_post_compact_input = _last_user_input or "Continue the current task." _base = _last_user_input or "Continue the current task."
_post_compact_input = (
f"{_base}\n\n"
"IMPORTANT: Your context window was just compacted. "
"Your session memory is already loaded above. "
"Review the 'Exhausted Approaches' section in your memory and "
"DO NOT repeat any technique, command, URL, port scan, or login "
"attempt already listed there. "
"Pick up exactly where you left off using only NEW approaches."
)
from cai.sdk.agents.simple_agent_manager import AGENT_MANAGER as _AM from cai.sdk.agents.simple_agent_manager import AGENT_MANAGER as _AM
_reloaded = _AM.get_active_agent() _reloaded = _AM.get_active_agent()
if _reloaded is not None: if _reloaded is not None:
@ -1683,7 +1692,16 @@ def run_cai_cli(
response = asyncio.run(Runner.run(agent, conversation_input)) response = asyncio.run(Runner.run(agent, conversation_input))
except ContextCompactedError: except ContextCompactedError:
# Auto-compact fired mid-runner; restart with fresh context. # Auto-compact fired mid-runner; restart with fresh context.
_post_compact_input = _last_user_input or "Continue the current task." _base = _last_user_input or "Continue the current task."
_post_compact_input = (
f"{_base}\n\n"
"IMPORTANT: Your context window was just compacted. "
"Your session memory is already loaded above. "
"Review the 'Exhausted Approaches' section in your memory and "
"DO NOT repeat any technique, command, URL, port scan, or login "
"attempt already listed there. "
"Pick up exactly where you left off using only NEW approaches."
)
from cai.sdk.agents.simple_agent_manager import AGENT_MANAGER as _AM from cai.sdk.agents.simple_agent_manager import AGENT_MANAGER as _AM
_reloaded = _AM.get_active_agent() _reloaded = _AM.get_active_agent()
if _reloaded is not None: if _reloaded is not None:

View File

@ -1267,39 +1267,60 @@ This session is being continued from a previous conversation that ran out of con
return None return None
def _format_history_for_summary(self, history: List[Dict[str, Any]]) -> str: def _format_history_for_summary(self, history: List[Dict[str, Any]]) -> str:
"""Format message history for summarization.""" """Format message history for summarization.
Critical design goals:
- Include EVERY tool call with its exact arguments (commands run, URLs visited,
ports scanned) so the summary model can produce an "Exhausted Approaches" list.
- Include enough of each tool result to convey success/failure and key findings.
- Avoid blowing out the summary model's context by capping large outputs.
"""
TOOL_OUTPUT_KEEP = 2000 # chars to preserve from each tool result
MAX_PARTS = 200 # maximum formatted blocks to pass (covers ~100 turns)
formatted_parts = [] formatted_parts = []
for msg in history: for msg in history:
role = msg.get("role", "unknown") role = msg.get("role", "unknown")
content = msg.get("content", "") content = msg.get("content", "")
# Skip empty messages
if not content:
continue
# Format based on role
if role == "user": if role == "user":
formatted_parts.append(f"USER: {content}") if content:
formatted_parts.append(f"USER: {content}")
elif role == "assistant": elif role == "assistant":
# Check for tool calls # -- tool calls: extract args from both dict-style and object-style entries --
if "tool_calls" in msg and msg["tool_calls"]: tool_calls = msg.get("tool_calls") or []
if tool_calls:
tool_info = [] tool_info = []
for tc in msg["tool_calls"]: for tc in tool_calls:
if hasattr(tc, "function"): if isinstance(tc, dict):
tool_info.append(f"{tc.function.name}({tc.function.arguments})") fn = tc.get("function", {})
name = fn.get("name", "?")
args = fn.get("arguments", "")
tool_info.append(f"{name}({args})")
elif hasattr(tc, "function"):
tool_info.append(
f"{tc.function.name}({tc.function.arguments})"
)
if tool_info: if tool_info:
formatted_parts.append(f"ASSISTANT (tools): {', '.join(tool_info)}") formatted_parts.append(
f"ASSISTANT called tools: {', '.join(tool_info)}"
)
if content: if content:
formatted_parts.append(f"ASSISTANT: {content}") formatted_parts.append(f"ASSISTANT: {content}")
elif role == "tool": elif role == "tool":
# Include important tool outputs raw = str(content) if content else ""
if len(str(content)) < 500: # Only include short outputs if len(raw) <= TOOL_OUTPUT_KEEP:
formatted_parts.append(f"TOOL OUTPUT: {content}") formatted_parts.append(f"TOOL OUTPUT:\n{raw}")
else: else:
formatted_parts.append(f"TOOL OUTPUT: [Long output truncated]") head = raw[:TOOL_OUTPUT_KEEP]
formatted_parts.append(
return "\n\n".join(formatted_parts[-50:]) # Limit to last 50 exchanges f"TOOL OUTPUT (truncated to {TOOL_OUTPUT_KEEP} chars):\n{head}\n[...truncated]"
)
return "\n\n".join(formatted_parts[-MAX_PARTS:])
def _get_current_agent_name(self) -> Optional[str]: def _get_current_agent_name(self) -> Optional[str]:
"""Get the name of the current active agent.""" """Get the name of the current active agent."""