openhack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. openhack/__init__.py +2 -0
  2. openhack/__main__.py +225 -0
  3. openhack/agents/__init__.py +30 -0
  4. openhack/agents/base.py +230 -0
  5. openhack/agents/browser_verifier.py +679 -0
  6. openhack/agents/browser_verifier_swarm.py +256 -0
  7. openhack/agents/checkpoint.py +89 -0
  8. openhack/agents/context_manager.py +356 -0
  9. openhack/agents/coordinator.py +1105 -0
  10. openhack/agents/endpoint_analyst.py +307 -0
  11. openhack/agents/feature_hunter.py +93 -0
  12. openhack/agents/hunter.py +481 -0
  13. openhack/agents/hunter_swarm.py +385 -0
  14. openhack/agents/llm.py +334 -0
  15. openhack/agents/recon.py +19 -0
  16. openhack/agents/sandbox_verifier.py +396 -0
  17. openhack/agents/sandbox_verifier_swarm.py +250 -0
  18. openhack/agents/session.py +286 -0
  19. openhack/agents/validator.py +217 -0
  20. openhack/agents/validator_swarm.py +106 -0
  21. openhack/auth.py +175 -0
  22. openhack/browser/__init__.py +12 -0
  23. openhack/browser/runner.py +385 -0
  24. openhack/categories.py +130 -0
  25. openhack/config.py +201 -0
  26. openhack/deterministic_recon.py +464 -0
  27. openhack/entry_points.py +745 -0
  28. openhack/framework_classifier.py +515 -0
  29. openhack/framework_detection.py +269 -0
  30. openhack/headless_scan.py +179 -0
  31. openhack/prompts/__init__.py +108 -0
  32. openhack/prompts/browser_verifier.py +171 -0
  33. openhack/prompts/coordinator.py +31 -0
  34. openhack/prompts/django/__init__.py +32 -0
  35. openhack/prompts/django/auth_bypass.py +76 -0
  36. openhack/prompts/django/csrf.py +62 -0
  37. openhack/prompts/django/data_exposure.py +67 -0
  38. openhack/prompts/django/idor.py +74 -0
  39. openhack/prompts/django/injection.py +67 -0
  40. openhack/prompts/django/misconfiguration.py +70 -0
  41. openhack/prompts/django/ssrf.py +64 -0
  42. openhack/prompts/endpoint_analyst.py +122 -0
  43. openhack/prompts/express/__init__.py +29 -0
  44. openhack/prompts/express/auth_bypass.py +71 -0
  45. openhack/prompts/express/data_exposure.py +77 -0
  46. openhack/prompts/express/idor.py +69 -0
  47. openhack/prompts/express/injection.py +75 -0
  48. openhack/prompts/express/misconfiguration.py +72 -0
  49. openhack/prompts/express/ssrf.py +63 -0
  50. openhack/prompts/feature_hunter.py +140 -0
  51. openhack/prompts/flask/__init__.py +29 -0
  52. openhack/prompts/flask/auth_bypass.py +86 -0
  53. openhack/prompts/flask/data_exposure.py +78 -0
  54. openhack/prompts/flask/idor.py +83 -0
  55. openhack/prompts/flask/injection.py +77 -0
  56. openhack/prompts/flask/misconfiguration.py +73 -0
  57. openhack/prompts/flask/ssrf.py +65 -0
  58. openhack/prompts/hunter.py +362 -0
  59. openhack/prompts/hunter_continuation_loop.py +12 -0
  60. openhack/prompts/hunter_continuation_no_findings.py +19 -0
  61. openhack/prompts/hunter_continuation_no_progress.py +22 -0
  62. openhack/prompts/hunter_tool_instructions.py +55 -0
  63. openhack/prompts/nextjs/__init__.py +42 -0
  64. openhack/prompts/nextjs/auth_bypass.py +80 -0
  65. openhack/prompts/nextjs/csrf.py +71 -0
  66. openhack/prompts/nextjs/data_exposure.py +88 -0
  67. openhack/prompts/nextjs/idor.py +64 -0
  68. openhack/prompts/nextjs/injection.py +65 -0
  69. openhack/prompts/nextjs/middleware_bypass.py +75 -0
  70. openhack/prompts/nextjs/misconfiguration.py +92 -0
  71. openhack/prompts/nextjs/server_actions.py +97 -0
  72. openhack/prompts/nextjs/ssrf.py +66 -0
  73. openhack/prompts/nextjs/xss.py +69 -0
  74. openhack/prompts/pr_analysis_system.py +80 -0
  75. openhack/prompts/pr_analysis_user.py +11 -0
  76. openhack/prompts/project_context.py +89 -0
  77. openhack/prompts/recon.py +199 -0
  78. openhack/prompts/reporter.py +88 -0
  79. openhack/prompts/researchers.py +434 -0
  80. openhack/prompts/sandbox_verifier.py +128 -0
  81. openhack/prompts/supabase/__init__.py +39 -0
  82. openhack/prompts/supabase/auth_tokens.py +131 -0
  83. openhack/prompts/supabase/edge_functions.py +150 -0
  84. openhack/prompts/supabase/graphql.py +102 -0
  85. openhack/prompts/supabase/postgrest.py +99 -0
  86. openhack/prompts/supabase/realtime.py +93 -0
  87. openhack/prompts/supabase/rls.py +110 -0
  88. openhack/prompts/supabase/rpc_functions.py +127 -0
  89. openhack/prompts/supabase/storage.py +110 -0
  90. openhack/prompts/supabase/tenant_isolation.py +118 -0
  91. openhack/prompts/validator.py +319 -0
  92. openhack/prompts/validator_continuation_incomplete.py +12 -0
  93. openhack/prompts/validator_tool_instructions.py +29 -0
  94. openhack/quality.py +231 -0
  95. openhack/sandbox/__init__.py +12 -0
  96. openhack/sandbox/orchestrator.py +517 -0
  97. openhack/sandbox/runner.py +177 -0
  98. openhack/scan_session.py +245 -0
  99. openhack/setup.py +452 -0
  100. openhack/static_validator.py +612 -0
  101. openhack/tools/__init__.py +1 -0
  102. openhack/tools/ast_tools.py +307 -0
  103. openhack/tools/coverage.py +1078 -0
  104. openhack/tools/filesystem.py +404 -0
  105. openhack/tools/nextjs.py +258 -0
  106. openhack/tools/registry.py +52 -0
  107. openhack/tui.py +3450 -0
  108. openhack/updates.py +170 -0
  109. openhack-0.1.0.dist-info/METADATA +189 -0
  110. openhack-0.1.0.dist-info/RECORD +113 -0
  111. openhack-0.1.0.dist-info/WHEEL +4 -0
  112. openhack-0.1.0.dist-info/entry_points.txt +2 -0
  113. openhack-0.1.0.dist-info/licenses/LICENSE +661 -0
openhack/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """OpenHack Agent - Interactive TUI for vulnerability scanning."""
2
+ __version__ = "0.1.0"
openhack/__main__.py ADDED
@@ -0,0 +1,225 @@
1
+ """
2
+ Entry point for OpenHack.
3
+
4
+ Usage:
5
+ openhack Launch interactive TUI
6
+ openhack scan [path] Scan a repository (headless, defaults to .)
7
+ openhack sessions List all saved scan sessions
8
+ openhack resume <id> Resume a previous scan session
9
+ openhack classify [path] Classify frameworks and detect entry points
10
+ openhack login Log in to your OpenHack account
11
+ openhack setup Run the setup wizard
12
+ openhack --help Show usage
13
+ """
14
+
15
+ import sys
16
+
17
+
18
+ def _cmd_scan():
19
+ """Run a headless scan on a directory."""
20
+ from pathlib import Path
21
+
22
+ target_arg = sys.argv[2] if len(sys.argv) > 2 else "."
23
+ target = Path(target_arg).resolve()
24
+
25
+ if not target.is_dir():
26
+ print(f"Error: '{target_arg}' is not a directory.")
27
+ print("Usage: openhack scan [path]")
28
+ return
29
+
30
+ from openhack.config import settings
31
+ if not settings.openhack_api_key:
32
+ print("Error: not logged in.")
33
+ print("Run 'openhack login' to set up your account, or set OPENHACK_API_KEY.")
34
+ return
35
+
36
+ import asyncio
37
+ from openhack.headless_scan import run_headless_scan
38
+ try:
39
+ asyncio.run(run_headless_scan(str(target)))
40
+ except KeyboardInterrupt:
41
+ print()
42
+ except Exception:
43
+ pass
44
+
45
+
46
+ def _cmd_sessions():
47
+ """List all saved scan sessions."""
48
+ import json
49
+ from pathlib import Path
50
+
51
+ scans_dir = Path.home() / ".openhack" / "scans"
52
+ if not scans_dir.exists():
53
+ print("\nNo saved scans yet.")
54
+ return
55
+
56
+ reports = []
57
+ for p in sorted(scans_dir.glob("*.json"), reverse=True):
58
+ try:
59
+ data = json.loads(p.read_text())
60
+ reports.append(data)
61
+ except (OSError, json.JSONDecodeError):
62
+ continue
63
+
64
+ print(f"\nSaved scans: {len(reports)}")
65
+ for r in reports:
66
+ scan_id = (r.get("scan_id") or "?")[:8]
67
+ target = r.get("target_dir", "?")
68
+ status = r.get("status", "?")
69
+ findings = r.get("findings", [])
70
+ started = r.get("started_at", "")[:16]
71
+ print(f" {scan_id} {target} [{status}] {len(findings)} findings {started}")
72
+
73
+
74
+ def _cmd_resume():
75
+ """Resume a previous scan session."""
76
+ import json
77
+ from pathlib import Path
78
+ from openhack.agents.checkpoint import CheckpointManager
79
+
80
+ session_id = sys.argv[2] if len(sys.argv) > 2 else None
81
+ if not session_id:
82
+ print("Usage: openhack resume <session_id>")
83
+ return
84
+
85
+ scans_dir = Path.home() / ".openhack" / "scans"
86
+ report_path = scans_dir / f"{session_id}.json"
87
+ if not report_path.exists():
88
+ matches = list(scans_dir.glob(f"{session_id}*.json"))
89
+ if matches:
90
+ report_path = matches[0]
91
+
92
+ if not report_path.exists():
93
+ print(f"Session {session_id} not found in ~/.openhack/scans/")
94
+ return
95
+
96
+ try:
97
+ report = json.loads(report_path.read_text())
98
+ except (json.JSONDecodeError, OSError):
99
+ print(f"Could not read session report: {report_path}")
100
+ return
101
+
102
+ target_dir = report.get("target_dir")
103
+ if not target_dir or not Path(target_dir).is_dir():
104
+ print(f"Target directory no longer exists: {target_dir}")
105
+ return
106
+
107
+ status = report.get("status", "")
108
+ if status == "completed":
109
+ findings = report.get("findings", [])
110
+ print(f"Session {session_id} already completed ({len(findings)} findings).")
111
+ return
112
+
113
+ mgr = CheckpointManager(session_id)
114
+ latest = mgr.get_latest_step()
115
+ if latest:
116
+ print(f"Resuming session {session_id} from checkpoint: {latest}")
117
+ else:
118
+ print(f"Resuming session {session_id} (no checkpoint — starting fresh)")
119
+
120
+ from openhack.config import settings
121
+ if not settings.openhack_api_key:
122
+ print("Error: not logged in.")
123
+ print("Run 'openhack login' to set up your account, or set OPENHACK_API_KEY.")
124
+ return
125
+
126
+ import asyncio
127
+ from openhack.headless_scan import run_headless_scan
128
+ try:
129
+ asyncio.run(run_headless_scan(target_dir, resume_from_checkpoint=session_id))
130
+ except KeyboardInterrupt:
131
+ print()
132
+ except Exception:
133
+ pass
134
+
135
+
136
+ def _cmd_classify():
137
+ """Classify frameworks and detect entry points."""
138
+ from pathlib import Path
139
+ from openhack.tools.registry import ToolRegistry
140
+ from openhack.framework_classifier import classify_frameworks
141
+ from openhack.entry_points import detect_entry_points
142
+ from openhack.scan_session import ScanSession
143
+ import uuid
144
+
145
+ target = sys.argv[2] if len(sys.argv) > 2 else "."
146
+ tools = ToolRegistry(target_dir=Path(target))
147
+
148
+ print(f"\nClassifying {target}...\n")
149
+ classifications = classify_frameworks(tools.fs_tools)
150
+ for c in classifications:
151
+ print(f" {c['root']} → {c['language']} [{', '.join(c['frameworks'])}]")
152
+
153
+ print(f"\nDetecting entry points...")
154
+ entry_points = detect_entry_points(tools.fs_tools, classifications)
155
+ print(f" {len(entry_points)} entry points found\n")
156
+
157
+ sid = str(uuid.uuid4())[:8]
158
+ session = ScanSession(sid, target)
159
+ session.classifications = classifications
160
+ session.entry_points = entry_points
161
+ session.save()
162
+ print(f" Session saved: {sid}")
163
+ print(f" Run 'openhack resume {sid}' to scan\n")
164
+
165
+
166
+ def _cmd_login():
167
+ """Run the device login flow."""
168
+ from openhack.setup import run_first_time_setup
169
+ run_first_time_setup()
170
+
171
+
172
+ def _cmd_setup():
173
+ """Run the setup wizard."""
174
+ from openhack.setup import run_first_time_setup
175
+ run_first_time_setup()
176
+
177
+
178
+ COMMANDS = {
179
+ "scan": _cmd_scan,
180
+ "sessions": _cmd_sessions,
181
+ "resume": _cmd_resume,
182
+ "classify": _cmd_classify,
183
+ "login": _cmd_login,
184
+ "setup": _cmd_setup,
185
+ }
186
+
187
+
188
+ def main():
189
+ if len(sys.argv) > 1:
190
+ cmd = sys.argv[1]
191
+
192
+ if cmd in ("--help", "-h", "help"):
193
+ print(__doc__)
194
+ return
195
+
196
+ if cmd in ("--version", "-v", "version"):
197
+ from openhack import __version__
198
+ print(f"openhack {__version__}")
199
+ return
200
+
201
+ if cmd in COMMANDS:
202
+ COMMANDS[cmd]()
203
+ return
204
+
205
+ print(f"Unknown command: {cmd}")
206
+ print("Run 'openhack --help' for usage.")
207
+ return
208
+
209
+ # Default: launch TUI
210
+ from openhack.setup import needs_first_time_setup, run_first_time_setup
211
+
212
+ try:
213
+ if needs_first_time_setup():
214
+ completed = run_first_time_setup()
215
+ if not completed:
216
+ print("\nSetup skipped. Run openhack again or use /setup inside the TUI.\n")
217
+
218
+ from openhack.tui import main as tui_main
219
+ tui_main()
220
+ except KeyboardInterrupt:
221
+ print()
222
+
223
+
224
+ if __name__ == "__main__":
225
+ main()
@@ -0,0 +1,30 @@
1
+ """OpenHack agents."""
2
+
3
+ from .session import Session, SessionStatus, Finding, TraceEntry
4
+ from .llm import LLMClient, Message, ToolCall, ToolResult, LLMResponse
5
+ from .base import BaseAgent
6
+ from .recon import ReconAgent
7
+ from .hunter import HunterAgent
8
+ from .hunter_swarm import HunterSwarmAgent
9
+ from .validator import ValidatorAgent
10
+ from .validator_swarm import ValidatorSwarmAgent
11
+ from .coordinator import CoordinatorAgent
12
+
13
+ __all__ = [
14
+ "Session",
15
+ "SessionStatus",
16
+ "Finding",
17
+ "TraceEntry",
18
+ "LLMClient",
19
+ "Message",
20
+ "ToolCall",
21
+ "ToolResult",
22
+ "LLMResponse",
23
+ "BaseAgent",
24
+ "ReconAgent",
25
+ "HunterAgent",
26
+ "HunterSwarmAgent",
27
+ "ValidatorAgent",
28
+ "ValidatorSwarmAgent",
29
+ "CoordinatorAgent",
30
+ ]
@@ -0,0 +1,230 @@
1
+ """
2
+ Base agent class for the multi-agent vulnerability scanning system.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ from abc import ABC, abstractmethod
8
+ from typing import Any, Optional
9
+
10
+ import openai
11
+
12
+ from .llm import LLMClient, Message, ToolCall, ToolResult
13
+ from .session import Session
14
+ from .context_manager import ContextWindowManager, MODEL_CONTEXT_LIMITS, DEFAULT_CONTEXT_LIMIT
15
+ from openhack.tools.registry import ToolRegistry
16
+ from openhack.config import settings
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class BaseAgent(ABC):
22
+ """Base class for all scanning agents."""
23
+
24
+ name: str = "base"
25
+ description: str = "Base agent"
26
+
27
+ def __init__(self, llm: LLMClient, tools: ToolRegistry, session: Session):
28
+ self.llm = llm
29
+ self.tools = tools
30
+ self.session = session
31
+ self.messages: list[Message] = []
32
+ self._instructions_watermark: int = 0
33
+
34
+ context_limit = MODEL_CONTEXT_LIMITS.get(llm.model, DEFAULT_CONTEXT_LIMIT)
35
+ self.context_manager = ContextWindowManager(
36
+ context_window_limit=context_limit,
37
+ compaction_threshold=settings.compaction_threshold,
38
+ tool_result_max_lines=settings.tool_result_max_lines,
39
+ )
40
+
41
+ @abstractmethod
42
+ def get_system_prompt(self, context: dict) -> str:
43
+ pass
44
+
45
+ def get_tools(self) -> list[dict]:
46
+ return self.tools.get_all_tool_definitions()
47
+
48
+ def _inject_pending_instructions(self) -> None:
49
+ """Pull any new user instructions from the session and append them to messages."""
50
+ new, version = self.session.get_new_instructions(self._instructions_watermark)
51
+ self._instructions_watermark = version
52
+ for instruction in new:
53
+ self.messages.append(Message(
54
+ role="user",
55
+ content=(
56
+ f"[USER INSTRUCTION]: {instruction}\n"
57
+ "Take this into account for the remainder of your analysis."
58
+ ),
59
+ ))
60
+
61
+ def _seed_existing_instructions(self) -> None:
62
+ """Inject any instructions that were given before this agent was created."""
63
+ existing = self.session.get_all_instructions()
64
+ self._instructions_watermark = len(existing)
65
+ if existing:
66
+ combined = "\n".join(f"- {inst}" for inst in existing)
67
+ self.messages.append(Message(
68
+ role="user",
69
+ content=(
70
+ f"[USER INSTRUCTIONS (given earlier in this scan)]:\n{combined}\n"
71
+ "Take these into account throughout your analysis."
72
+ ),
73
+ ))
74
+
75
+ def _estimate_tokens(self, messages: list[Message], system: str) -> int:
76
+ """Rough token estimate: ~4 chars per token for English text."""
77
+ total_chars = len(system)
78
+ for msg in messages:
79
+ if msg.content:
80
+ total_chars += len(msg.content)
81
+ if msg.tool_calls:
82
+ total_chars += len(json.dumps(msg.tool_calls))
83
+ return total_chars // 4
84
+
85
+ def _preflight_compact(self, system_prompt: str) -> None:
86
+ """Compact messages before sending to LLM if estimated tokens exceed limit."""
87
+ estimated = self._estimate_tokens(self.messages, system_prompt)
88
+ limit = self.context_manager.context_window_limit
89
+
90
+ if estimated > limit * 0.85:
91
+ logger.warning(
92
+ f"[{self.name}] Pre-flight: estimated {estimated} tokens vs {limit} limit — compacting"
93
+ )
94
+ self.messages = self.context_manager.compact_messages(self.messages, keep_recent_turns=3)
95
+ estimated = self._estimate_tokens(self.messages, system_prompt)
96
+
97
+ if estimated > limit * 0.85:
98
+ logger.warning(
99
+ f"[{self.name}] Still {estimated} tokens after normal compaction — emergency compaction"
100
+ )
101
+ self.messages = self.context_manager.emergency_compact(self.messages)
102
+
103
+ async def run(self, task: str, context: Optional[dict] = None) -> dict:
104
+ context = context or {}
105
+ self.session.current_agent = self.name
106
+
107
+ system_prompt = self.get_system_prompt(context)
108
+ self.messages = [Message(role="user", content=task)]
109
+ self._seed_existing_instructions()
110
+
111
+ max_iterations = 50
112
+ iteration = 0
113
+
114
+ while iteration < max_iterations:
115
+ if self.session.cancelled:
116
+ break
117
+ # Block here while the session is paused (no-op when running).
118
+ await self.session.wait_if_paused()
119
+ if self.session.cancelled:
120
+ break
121
+ iteration += 1
122
+
123
+ self._inject_pending_instructions()
124
+
125
+ self._preflight_compact(system_prompt)
126
+
127
+ try:
128
+ response = await self.llm.chat(
129
+ messages=self.messages,
130
+ tools=self.get_tools(),
131
+ system=system_prompt,
132
+ )
133
+ except openai.BadRequestError as e:
134
+ err_msg = str(e)
135
+ if "too long" in err_msg or "too many tokens" in err_msg.lower() or "context length" in err_msg:
136
+ logger.warning(f"[{self.name}] Context overflow on LLM call — compacting and retrying")
137
+ self.messages = self.context_manager.compact_messages(self.messages, keep_recent_turns=2)
138
+ estimated = self._estimate_tokens(self.messages, system_prompt)
139
+ if estimated > self.context_manager.context_window_limit * 0.85:
140
+ self.messages = self.context_manager.emergency_compact(self.messages)
141
+
142
+ try:
143
+ response = await self.llm.chat(
144
+ messages=self.messages,
145
+ tools=self.get_tools(),
146
+ system=system_prompt,
147
+ )
148
+ except openai.BadRequestError as e2:
149
+ err_msg2 = str(e2)
150
+ if "too long" in err_msg2 or "context length" in err_msg2:
151
+ logger.warning(f"[{self.name}] Still overflowing after emergency compaction — final attempt")
152
+ self.messages = self.context_manager.emergency_compact(self.messages)
153
+ response = await self.llm.chat(
154
+ messages=self.messages,
155
+ tools=self.get_tools(),
156
+ system=system_prompt,
157
+ )
158
+ else:
159
+ raise
160
+ else:
161
+ raise
162
+
163
+ self.session.total_cost += response.cost
164
+ if response.usage:
165
+ self.session.total_tokens += response.usage.get("total_tokens", 0)
166
+ self.context_manager.update_usage(response.usage.get("input_tokens", 0))
167
+
168
+ if response.content:
169
+ self.session.add_trace(
170
+ agent=self.name,
171
+ event_type="thinking",
172
+ content=response.content,
173
+ )
174
+
175
+ if not response.tool_calls:
176
+ return self._parse_final_response(response.content or "")
177
+
178
+ assistant_msg = Message(
179
+ role="assistant",
180
+ content=response.content,
181
+ tool_calls=[
182
+ {
183
+ "id": tc.id,
184
+ "type": "function",
185
+ "function": {"name": tc.name, "arguments": json.dumps(tc.arguments)},
186
+ }
187
+ for tc in response.tool_calls
188
+ ],
189
+ reasoning_content=getattr(response, 'reasoning_content', None),
190
+ )
191
+ self.messages.append(assistant_msg)
192
+
193
+ for tool_call in response.tool_calls:
194
+ self.session.add_trace(
195
+ agent=self.name,
196
+ event_type="tool_call",
197
+ content=f"Calling {tool_call.name}",
198
+ tool_name=tool_call.name,
199
+ tool_input=tool_call.arguments,
200
+ )
201
+
202
+ if self.tools.is_async_tool(tool_call.name):
203
+ result = await self.tools.execute_tool_async(tool_call.name, tool_call.arguments)
204
+ else:
205
+ result = self.tools.execute_tool(tool_call.name, tool_call.arguments)
206
+
207
+ self.session.add_trace(
208
+ agent=self.name,
209
+ event_type="tool_result",
210
+ content=f"Result from {tool_call.name}",
211
+ tool_name=tool_call.name,
212
+ tool_output=result,
213
+ )
214
+
215
+ raw_content = json.dumps(result) if isinstance(result, dict) else str(result)
216
+ truncated_content = self.context_manager.truncate_tool_result(tool_call.name, raw_content)
217
+ tool_result = ToolResult(
218
+ tool_call_id=tool_call.id,
219
+ content=truncated_content,
220
+ )
221
+ self.messages.append(tool_result.to_message())
222
+
223
+ if self.context_manager.needs_compaction():
224
+ self.messages = self.context_manager.compact_messages(self.messages)
225
+ logger.info(f"[{self.name}] Compacted message history ({self.context_manager.last_input_tokens} input tokens)")
226
+
227
+ return {"error": "Max iterations reached", "partial_result": self.messages[-1].content}
228
+
229
+ def _parse_final_response(self, content: str) -> dict:
230
+ return {"response": content}