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.
- openhack/__init__.py +2 -0
- openhack/__main__.py +225 -0
- openhack/agents/__init__.py +30 -0
- openhack/agents/base.py +230 -0
- openhack/agents/browser_verifier.py +679 -0
- openhack/agents/browser_verifier_swarm.py +256 -0
- openhack/agents/checkpoint.py +89 -0
- openhack/agents/context_manager.py +356 -0
- openhack/agents/coordinator.py +1105 -0
- openhack/agents/endpoint_analyst.py +307 -0
- openhack/agents/feature_hunter.py +93 -0
- openhack/agents/hunter.py +481 -0
- openhack/agents/hunter_swarm.py +385 -0
- openhack/agents/llm.py +334 -0
- openhack/agents/recon.py +19 -0
- openhack/agents/sandbox_verifier.py +396 -0
- openhack/agents/sandbox_verifier_swarm.py +250 -0
- openhack/agents/session.py +286 -0
- openhack/agents/validator.py +217 -0
- openhack/agents/validator_swarm.py +106 -0
- openhack/auth.py +175 -0
- openhack/browser/__init__.py +12 -0
- openhack/browser/runner.py +385 -0
- openhack/categories.py +130 -0
- openhack/config.py +201 -0
- openhack/deterministic_recon.py +464 -0
- openhack/entry_points.py +745 -0
- openhack/framework_classifier.py +515 -0
- openhack/framework_detection.py +269 -0
- openhack/headless_scan.py +179 -0
- openhack/prompts/__init__.py +108 -0
- openhack/prompts/browser_verifier.py +171 -0
- openhack/prompts/coordinator.py +31 -0
- openhack/prompts/django/__init__.py +32 -0
- openhack/prompts/django/auth_bypass.py +76 -0
- openhack/prompts/django/csrf.py +62 -0
- openhack/prompts/django/data_exposure.py +67 -0
- openhack/prompts/django/idor.py +74 -0
- openhack/prompts/django/injection.py +67 -0
- openhack/prompts/django/misconfiguration.py +70 -0
- openhack/prompts/django/ssrf.py +64 -0
- openhack/prompts/endpoint_analyst.py +122 -0
- openhack/prompts/express/__init__.py +29 -0
- openhack/prompts/express/auth_bypass.py +71 -0
- openhack/prompts/express/data_exposure.py +77 -0
- openhack/prompts/express/idor.py +69 -0
- openhack/prompts/express/injection.py +75 -0
- openhack/prompts/express/misconfiguration.py +72 -0
- openhack/prompts/express/ssrf.py +63 -0
- openhack/prompts/feature_hunter.py +140 -0
- openhack/prompts/flask/__init__.py +29 -0
- openhack/prompts/flask/auth_bypass.py +86 -0
- openhack/prompts/flask/data_exposure.py +78 -0
- openhack/prompts/flask/idor.py +83 -0
- openhack/prompts/flask/injection.py +77 -0
- openhack/prompts/flask/misconfiguration.py +73 -0
- openhack/prompts/flask/ssrf.py +65 -0
- openhack/prompts/hunter.py +362 -0
- openhack/prompts/hunter_continuation_loop.py +12 -0
- openhack/prompts/hunter_continuation_no_findings.py +19 -0
- openhack/prompts/hunter_continuation_no_progress.py +22 -0
- openhack/prompts/hunter_tool_instructions.py +55 -0
- openhack/prompts/nextjs/__init__.py +42 -0
- openhack/prompts/nextjs/auth_bypass.py +80 -0
- openhack/prompts/nextjs/csrf.py +71 -0
- openhack/prompts/nextjs/data_exposure.py +88 -0
- openhack/prompts/nextjs/idor.py +64 -0
- openhack/prompts/nextjs/injection.py +65 -0
- openhack/prompts/nextjs/middleware_bypass.py +75 -0
- openhack/prompts/nextjs/misconfiguration.py +92 -0
- openhack/prompts/nextjs/server_actions.py +97 -0
- openhack/prompts/nextjs/ssrf.py +66 -0
- openhack/prompts/nextjs/xss.py +69 -0
- openhack/prompts/pr_analysis_system.py +80 -0
- openhack/prompts/pr_analysis_user.py +11 -0
- openhack/prompts/project_context.py +89 -0
- openhack/prompts/recon.py +199 -0
- openhack/prompts/reporter.py +88 -0
- openhack/prompts/researchers.py +434 -0
- openhack/prompts/sandbox_verifier.py +128 -0
- openhack/prompts/supabase/__init__.py +39 -0
- openhack/prompts/supabase/auth_tokens.py +131 -0
- openhack/prompts/supabase/edge_functions.py +150 -0
- openhack/prompts/supabase/graphql.py +102 -0
- openhack/prompts/supabase/postgrest.py +99 -0
- openhack/prompts/supabase/realtime.py +93 -0
- openhack/prompts/supabase/rls.py +110 -0
- openhack/prompts/supabase/rpc_functions.py +127 -0
- openhack/prompts/supabase/storage.py +110 -0
- openhack/prompts/supabase/tenant_isolation.py +118 -0
- openhack/prompts/validator.py +319 -0
- openhack/prompts/validator_continuation_incomplete.py +12 -0
- openhack/prompts/validator_tool_instructions.py +29 -0
- openhack/quality.py +231 -0
- openhack/sandbox/__init__.py +12 -0
- openhack/sandbox/orchestrator.py +517 -0
- openhack/sandbox/runner.py +177 -0
- openhack/scan_session.py +245 -0
- openhack/setup.py +452 -0
- openhack/static_validator.py +612 -0
- openhack/tools/__init__.py +1 -0
- openhack/tools/ast_tools.py +307 -0
- openhack/tools/coverage.py +1078 -0
- openhack/tools/filesystem.py +404 -0
- openhack/tools/nextjs.py +258 -0
- openhack/tools/registry.py +52 -0
- openhack/tui.py +3450 -0
- openhack/updates.py +170 -0
- openhack-0.1.0.dist-info/METADATA +189 -0
- openhack-0.1.0.dist-info/RECORD +113 -0
- openhack-0.1.0.dist-info/WHEEL +4 -0
- openhack-0.1.0.dist-info/entry_points.txt +2 -0
- openhack-0.1.0.dist-info/licenses/LICENSE +661 -0
openhack/__init__.py
ADDED
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
|
+
]
|
openhack/agents/base.py
ADDED
|
@@ -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}
|