r3d-agent 0.2.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.
- r3d/__init__.py +3 -0
- r3d/__main__.py +6 -0
- r3d/agent.py +638 -0
- r3d/audit.py +161 -0
- r3d/cli.py +722 -0
- r3d/config.py +308 -0
- r3d/context.py +218 -0
- r3d/data/SKILLS.md +212 -0
- r3d/data/__init__.py +0 -0
- r3d/errors.py +102 -0
- r3d/exploit.py +294 -0
- r3d/exploits/__init__.py +25 -0
- r3d/exploits/web/__init__.py +5 -0
- r3d/exploits/web/php_rce_arg_injection.py +67 -0
- r3d/logger.py +19 -0
- r3d/payloads.py +277 -0
- r3d/planner.py +127 -0
- r3d/providers/__init__.py +21 -0
- r3d/providers/anthropic_provider.py +165 -0
- r3d/providers/base.py +92 -0
- r3d/providers/openai_provider.py +216 -0
- r3d/providers/retry.py +121 -0
- r3d/redact.py +46 -0
- r3d/remote.py +230 -0
- r3d/repl.py +478 -0
- r3d/report.py +277 -0
- r3d/safety.py +153 -0
- r3d/skills.py +55 -0
- r3d/system_prompt.py +130 -0
- r3d/tools/__init__.py +20 -0
- r3d/tools/base.py +155 -0
- r3d/tools/exploit_tool.py +149 -0
- r3d/tools/file.py +118 -0
- r3d/tools/finding.py +76 -0
- r3d/tools/post.py +296 -0
- r3d/tools/registry.py +182 -0
- r3d/tools/shell.py +201 -0
- r3d/tools/web.py +119 -0
- r3d/ui/__init__.py +17 -0
- r3d/ui/branding.py +89 -0
- r3d/ui/commands.py +119 -0
- r3d/ui/layout.py +250 -0
- r3d/ui/messages.py +33 -0
- r3d/ui/panes/__init__.py +11 -0
- r3d/ui/panes/command.py +99 -0
- r3d/ui/panes/finding.py +132 -0
- r3d/ui/panes/output.py +122 -0
- r3d/ui/prompt.py +97 -0
- r3d/ui/renderer.py +782 -0
- r3d_agent-0.2.0.dist-info/METADATA +203 -0
- r3d_agent-0.2.0.dist-info/RECORD +54 -0
- r3d_agent-0.2.0.dist-info/WHEEL +4 -0
- r3d_agent-0.2.0.dist-info/entry_points.txt +2 -0
- r3d_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
r3d/__init__.py
ADDED
r3d/__main__.py
ADDED
r3d/agent.py
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
"""ReAct loop: plan → execute → observe → analyze → repeat (§4/§5 agent.py).
|
|
2
|
+
|
|
3
|
+
The core agent loop. Includes ToolCallState, _stream_with_failover,
|
|
4
|
+
and proper message building for all provider types.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import TYPE_CHECKING, AsyncIterator
|
|
17
|
+
|
|
18
|
+
from r3d import redact
|
|
19
|
+
from r3d.audit import AuditEvent, AuditLog
|
|
20
|
+
from r3d.context import Session
|
|
21
|
+
from r3d.errors import FatalProviderError, RateLimitError, TransientProviderError, with_retry
|
|
22
|
+
from r3d.logger import Logger
|
|
23
|
+
from r3d.planner import Planner
|
|
24
|
+
from r3d.providers.base import (
|
|
25
|
+
ProviderEvent,
|
|
26
|
+
StreamDone,
|
|
27
|
+
TextDelta,
|
|
28
|
+
ToolCallComplete,
|
|
29
|
+
ToolCallDelta,
|
|
30
|
+
ToolCallStart,
|
|
31
|
+
)
|
|
32
|
+
from r3d.safety import SafetyChecker, SafetyDecision
|
|
33
|
+
from r3d.system_prompt import build_system_prompt
|
|
34
|
+
from r3d.tools.registry import ToolRegistry
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from r3d.config import Config
|
|
38
|
+
from r3d.providers.base import LLMProvider
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ToolCallState:
|
|
45
|
+
"""Tracks a pending tool call during streaming."""
|
|
46
|
+
id: str
|
|
47
|
+
name: str
|
|
48
|
+
args_json: str = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class AgentResult:
|
|
53
|
+
goal: str
|
|
54
|
+
steps_taken: int
|
|
55
|
+
elapsed_seconds: float
|
|
56
|
+
stop_reason: str # done|step_limit|time_limit|token_limit|interrupted|provider_error
|
|
57
|
+
summary: str
|
|
58
|
+
session_id: str
|
|
59
|
+
findings: list = field(default_factory=list)
|
|
60
|
+
tool_calls: int = 0
|
|
61
|
+
tool_errors: int = 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Agent:
|
|
65
|
+
"""The core ReAct agent."""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
provider: "LLMProvider",
|
|
70
|
+
tools: ToolRegistry,
|
|
71
|
+
session: Session,
|
|
72
|
+
log: Logger,
|
|
73
|
+
config: "Config",
|
|
74
|
+
safety: SafetyChecker,
|
|
75
|
+
audit: AuditLog,
|
|
76
|
+
failover_provider: "LLMProvider | None" = None,
|
|
77
|
+
):
|
|
78
|
+
self._provider = provider
|
|
79
|
+
self._failover = failover_provider
|
|
80
|
+
self._tools = tools
|
|
81
|
+
self._session = session
|
|
82
|
+
self._logger = log
|
|
83
|
+
self._config = config
|
|
84
|
+
self._safety = safety
|
|
85
|
+
self._audit = audit
|
|
86
|
+
self._interrupted = False
|
|
87
|
+
self._total_tool_calls = 0
|
|
88
|
+
self._total_tool_errors = 0
|
|
89
|
+
self._replans_used = 0
|
|
90
|
+
self._created_files: list[str] = [] # track files created by agent
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------ #
|
|
93
|
+
# Main entry point #
|
|
94
|
+
# ------------------------------------------------------------------ #
|
|
95
|
+
|
|
96
|
+
async def run(self, goal: str) -> AgentResult:
|
|
97
|
+
"""Run the agent to completion."""
|
|
98
|
+
try:
|
|
99
|
+
signal.signal(signal.SIGINT, self._graceful_shutdown)
|
|
100
|
+
except (OSError, ValueError):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
# Snapshot files before run — for cleanup later
|
|
104
|
+
self._pre_snapshot = self._snapshot_files()
|
|
105
|
+
|
|
106
|
+
self._audit.record(AuditEvent(
|
|
107
|
+
ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
108
|
+
session_id=self._session.session_id,
|
|
109
|
+
kind="session", tool="-", target=goal[:80],
|
|
110
|
+
decision="started",
|
|
111
|
+
))
|
|
112
|
+
|
|
113
|
+
# Phase 1: Planning — goal as first user message
|
|
114
|
+
self._session.add_message("user", f"Goal: {goal}")
|
|
115
|
+
|
|
116
|
+
if self._config.enable_planner:
|
|
117
|
+
try:
|
|
118
|
+
planner = Planner(self._provider, self._logger)
|
|
119
|
+
plan = await with_retry(
|
|
120
|
+
lambda: planner.make_plan(goal),
|
|
121
|
+
max_retries=self._config.max_retries,
|
|
122
|
+
base_delay=self._config.retry_base_delay,
|
|
123
|
+
)
|
|
124
|
+
self._logger.plan_display(plan)
|
|
125
|
+
self._session.plan = plan.steps
|
|
126
|
+
self._session.add_message("assistant", plan.raw_text)
|
|
127
|
+
self._session.add_message(
|
|
128
|
+
"user",
|
|
129
|
+
f"ดำเนินการตามแผนข้างต้นเพื่อบรรลุเป้าหมาย: {goal}",
|
|
130
|
+
)
|
|
131
|
+
self._session.save()
|
|
132
|
+
except Exception as e:
|
|
133
|
+
self._logger.warn(f"Planning failed: {e} — proceeding without plan")
|
|
134
|
+
|
|
135
|
+
stop_reason = "done"
|
|
136
|
+
full_text = ""
|
|
137
|
+
|
|
138
|
+
# Phase 2: ReAct loop (runs until <DONE>, time, or token/step limit)
|
|
139
|
+
max_steps = self._config.max_steps or 0
|
|
140
|
+
steps_since_last_tool = 0
|
|
141
|
+
while True:
|
|
142
|
+
# Check step limit
|
|
143
|
+
if max_steps and self._session.step_count >= max_steps:
|
|
144
|
+
stop_reason = "step_limit"
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
reason = self._check_stop()
|
|
148
|
+
if reason:
|
|
149
|
+
stop_reason = reason
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
self._session.step_count += 1
|
|
153
|
+
elapsed = time.time() - self._session.created_at
|
|
154
|
+
self._logger.step_banner(
|
|
155
|
+
self._session.step_count,
|
|
156
|
+
elapsed, self._session.estimated_tokens,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if self._session.estimated_tokens > self._config.max_tokens * 0.8:
|
|
160
|
+
self._session.compact()
|
|
161
|
+
self._logger.warn("Context compacted — oldest messages removed.")
|
|
162
|
+
|
|
163
|
+
messages = self._build_messages()
|
|
164
|
+
system = build_system_prompt(self._tools.list_tools(), self._config)
|
|
165
|
+
schemas = self._tools.get_schemas(
|
|
166
|
+
"anthropic" if self._config.provider == "anthropic" else "openai"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Stream with failover
|
|
170
|
+
full_text = ""
|
|
171
|
+
tool_calls_this_step: list[tuple[str, str, dict, str]] = []
|
|
172
|
+
pending: dict[str, ToolCallState] = {}
|
|
173
|
+
|
|
174
|
+
self._logger.start_thinking("กำลังคิด")
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
async for event in self._stream_with_failover(
|
|
178
|
+
messages, system, schemas
|
|
179
|
+
):
|
|
180
|
+
if isinstance(event, TextDelta):
|
|
181
|
+
self._logger.stream_text(event.text)
|
|
182
|
+
full_text += event.text
|
|
183
|
+
|
|
184
|
+
elif isinstance(event, ToolCallStart):
|
|
185
|
+
self._logger.stop_thinking()
|
|
186
|
+
self._logger.end_stream()
|
|
187
|
+
pending[event.id] = ToolCallState(
|
|
188
|
+
id=event.id, name=event.name
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
elif isinstance(event, ToolCallDelta):
|
|
192
|
+
if event.id in pending:
|
|
193
|
+
pending[event.id].args_json += event.json_fragment
|
|
194
|
+
|
|
195
|
+
elif isinstance(event, ToolCallComplete):
|
|
196
|
+
result_str = await self._handle_tool_call(
|
|
197
|
+
event.id, event.name, event.args
|
|
198
|
+
)
|
|
199
|
+
tool_calls_this_step.append(
|
|
200
|
+
(event.id, event.name, event.args, result_str)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
elif isinstance(event, StreamDone):
|
|
204
|
+
self._session.estimated_tokens += (
|
|
205
|
+
event.input_tokens + event.output_tokens
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
except FatalProviderError as e:
|
|
209
|
+
self._logger.stop_thinking()
|
|
210
|
+
self._logger.error(f"Provider error (fatal): {e}")
|
|
211
|
+
stop_reason = "provider_error"
|
|
212
|
+
break
|
|
213
|
+
except Exception as e:
|
|
214
|
+
self._logger.stop_thinking()
|
|
215
|
+
self._logger.error(f"Provider error: {e}")
|
|
216
|
+
stop_reason = "provider_error"
|
|
217
|
+
break
|
|
218
|
+
|
|
219
|
+
self._logger.stop_thinking()
|
|
220
|
+
self._logger.end_stream()
|
|
221
|
+
|
|
222
|
+
# ── Text-based tool call fallback ──────────────────────
|
|
223
|
+
# When models don't support native function calling, they
|
|
224
|
+
# write tool calls as text: [Tool Call: name] {json_args}
|
|
225
|
+
# Parse and execute these as real tool calls.
|
|
226
|
+
if not tool_calls_this_step:
|
|
227
|
+
text_calls = self._parse_text_tool_calls(full_text)
|
|
228
|
+
if text_calls:
|
|
229
|
+
for tc_id, tc_name, tc_args in text_calls:
|
|
230
|
+
result_str = await self._handle_tool_call(
|
|
231
|
+
tc_id, tc_name, tc_args
|
|
232
|
+
)
|
|
233
|
+
tool_calls_this_step.append(
|
|
234
|
+
(tc_id, tc_name, tc_args, result_str)
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
# No parseable tool calls found — check if model output
|
|
238
|
+
# contains only a <DONE> sentinel or natural language.
|
|
239
|
+
# If no <DONE> and no tool calls, we'll nudge below.
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
# Persist
|
|
243
|
+
self._session.add_assistant_turn(full_text, tool_calls_this_step)
|
|
244
|
+
self._session.updated_at = time.time()
|
|
245
|
+
self._session.save()
|
|
246
|
+
|
|
247
|
+
# Sentinels
|
|
248
|
+
if "<DONE>" in full_text:
|
|
249
|
+
stop_reason = "done"
|
|
250
|
+
break
|
|
251
|
+
|
|
252
|
+
if (m := re.search(r"<REPLAN:\s*(.+?)>", full_text)) and self._replans_left():
|
|
253
|
+
try:
|
|
254
|
+
planner = Planner(self._provider, self._logger)
|
|
255
|
+
plan = await planner.replan(
|
|
256
|
+
goal, self._session.plan, [m.group(1)]
|
|
257
|
+
)
|
|
258
|
+
self._logger.plan_display(plan)
|
|
259
|
+
self._session.plan = plan.steps
|
|
260
|
+
self._replans_used += 1
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
# Nudge if stalled — remind to use tools AND record findings
|
|
265
|
+
if not tool_calls_this_step and "<DONE>" not in full_text:
|
|
266
|
+
findings_count = len(self._session.findings)
|
|
267
|
+
if findings_count == 0:
|
|
268
|
+
self._session.add_message(
|
|
269
|
+
"user",
|
|
270
|
+
"ดำเนินการต่อ ใช้ tools เพื่อทำงาน "
|
|
271
|
+
"สำคัญ: เมื่อพบข้อมูลที่น่าสนใจหรือช่องโหว่ ให้ใช้ record_finding บันทึกทันที "
|
|
272
|
+
"อย่าลืมบันทึก findings ก่อนส่ง <DONE>",
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
self._session.add_message(
|
|
276
|
+
"user",
|
|
277
|
+
"ดำเนินการต่อ ใช้ tools เพื่อทำงาน หรือส่ง <DONE> เมื่อเสร็จสิ้น",
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Summary
|
|
281
|
+
result = AgentResult(
|
|
282
|
+
goal=goal,
|
|
283
|
+
steps_taken=self._session.step_count,
|
|
284
|
+
elapsed_seconds=time.time() - self._session.created_at,
|
|
285
|
+
stop_reason=stop_reason,
|
|
286
|
+
summary=self._extract_summary(full_text),
|
|
287
|
+
session_id=self._session.session_id,
|
|
288
|
+
findings=self._session.findings,
|
|
289
|
+
tool_calls=self._total_tool_calls,
|
|
290
|
+
tool_errors=self._total_tool_errors,
|
|
291
|
+
)
|
|
292
|
+
self._logger.session_summary(result)
|
|
293
|
+
self._session.save()
|
|
294
|
+
|
|
295
|
+
self._audit.record(AuditEvent(
|
|
296
|
+
ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
297
|
+
session_id=self._session.session_id,
|
|
298
|
+
kind="session", tool="-", target=goal[:80],
|
|
299
|
+
decision="completed", note=stop_reason,
|
|
300
|
+
))
|
|
301
|
+
|
|
302
|
+
# Cleanup files created by the agent
|
|
303
|
+
self._cleanup_files()
|
|
304
|
+
|
|
305
|
+
return result
|
|
306
|
+
|
|
307
|
+
# ------------------------------------------------------------------ #
|
|
308
|
+
# Stream with failover (§12 retry.py) #
|
|
309
|
+
# ------------------------------------------------------------------ #
|
|
310
|
+
|
|
311
|
+
async def _stream_with_failover(
|
|
312
|
+
self,
|
|
313
|
+
messages: list[dict],
|
|
314
|
+
system: str,
|
|
315
|
+
schemas: list[dict],
|
|
316
|
+
) -> AsyncIterator[ProviderEvent]:
|
|
317
|
+
"""Try primary provider, then failover on transient/rate errors."""
|
|
318
|
+
providers = [self._provider]
|
|
319
|
+
if self._failover:
|
|
320
|
+
providers.append(self._failover)
|
|
321
|
+
|
|
322
|
+
for i, provider in enumerate(providers):
|
|
323
|
+
try:
|
|
324
|
+
stream = provider.stream_with_tools(
|
|
325
|
+
messages, system, schemas,
|
|
326
|
+
temperature=self._config.temperature,
|
|
327
|
+
max_tokens=self._config.max_output_tokens,
|
|
328
|
+
)
|
|
329
|
+
async for event in stream:
|
|
330
|
+
yield event
|
|
331
|
+
return # success
|
|
332
|
+
except FatalProviderError:
|
|
333
|
+
raise # never retry fatal
|
|
334
|
+
except (RateLimitError, TransientProviderError) as e:
|
|
335
|
+
if i < len(providers) - 1:
|
|
336
|
+
self._logger.warn(f"Primary failed ({e}), switching to failover")
|
|
337
|
+
self._audit.record(AuditEvent(
|
|
338
|
+
ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
339
|
+
session_id=self._session.session_id,
|
|
340
|
+
kind="session", tool="-",
|
|
341
|
+
decision="allowed", note=f"failover: {e}",
|
|
342
|
+
))
|
|
343
|
+
continue
|
|
344
|
+
raise
|
|
345
|
+
|
|
346
|
+
# ------------------------------------------------------------------ #
|
|
347
|
+
# Tool call handler #
|
|
348
|
+
# ------------------------------------------------------------------ #
|
|
349
|
+
|
|
350
|
+
async def _handle_tool_call(
|
|
351
|
+
self, tc_id: str, name: str, args: dict
|
|
352
|
+
) -> str:
|
|
353
|
+
"""Safety → audit → execute → audit."""
|
|
354
|
+
args_summary = redact.scrub_str(json.dumps(args, default=str))[:120]
|
|
355
|
+
self._logger.tool_call_box(name, args) # pass full dict, not truncated string
|
|
356
|
+
self._total_tool_calls += 1
|
|
357
|
+
|
|
358
|
+
# 1) Safety
|
|
359
|
+
decision = SafetyDecision(allowed=True, reason="no check needed")
|
|
360
|
+
if name == "run_cmd":
|
|
361
|
+
decision = self._safety.check_command(args.get("cmd", ""))
|
|
362
|
+
elif name == "file" and args.get("action") == "write":
|
|
363
|
+
decision = self._safety.check_file_write(args.get("path", ""))
|
|
364
|
+
|
|
365
|
+
if not decision.allowed:
|
|
366
|
+
self._logger.warn(f"BLOCKED: {decision.reason}")
|
|
367
|
+
self._audit.record(AuditEvent(
|
|
368
|
+
ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
369
|
+
session_id=self._session.session_id,
|
|
370
|
+
kind="denied", tool=name, args_summary=args_summary,
|
|
371
|
+
decision="denied", risk_level=decision.risk_level,
|
|
372
|
+
matched_pattern=decision.matched_pattern,
|
|
373
|
+
))
|
|
374
|
+
self._total_tool_errors += 1
|
|
375
|
+
return f"ERROR: Blocked by safety policy — {decision.reason}"
|
|
376
|
+
|
|
377
|
+
if decision.requires_confirm:
|
|
378
|
+
if not self._logger.confirm_prompt(
|
|
379
|
+
f"[{decision.risk_level.upper()}] Allow: {args_summary[:80]}?"
|
|
380
|
+
):
|
|
381
|
+
self._audit.record(AuditEvent(
|
|
382
|
+
ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
383
|
+
session_id=self._session.session_id,
|
|
384
|
+
kind="denied", tool=name, args_summary=args_summary,
|
|
385
|
+
decision="denied",
|
|
386
|
+
))
|
|
387
|
+
return "ERROR: User declined this command."
|
|
388
|
+
self._audit.record(AuditEvent(
|
|
389
|
+
ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
390
|
+
session_id=self._session.session_id,
|
|
391
|
+
kind="confirm", tool=name, args_summary=args_summary,
|
|
392
|
+
decision="confirmed",
|
|
393
|
+
))
|
|
394
|
+
|
|
395
|
+
# 2) Audit intent
|
|
396
|
+
self._audit.record(AuditEvent(
|
|
397
|
+
ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
398
|
+
session_id=self._session.session_id,
|
|
399
|
+
kind="intent", tool=name, args_summary=args_summary,
|
|
400
|
+
decision="allowed", risk_level=decision.risk_level,
|
|
401
|
+
))
|
|
402
|
+
|
|
403
|
+
# 3) Execute
|
|
404
|
+
t0 = time.time()
|
|
405
|
+
try:
|
|
406
|
+
tool_result = await self._tools.dispatch(name, args)
|
|
407
|
+
elapsed = time.time() - t0
|
|
408
|
+
output = tool_result.format_output(max_lines=100)
|
|
409
|
+
self._logger.tool_result_box(
|
|
410
|
+
name, output, tool_result.truncated, elapsed
|
|
411
|
+
)
|
|
412
|
+
self._audit.record(AuditEvent(
|
|
413
|
+
ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
414
|
+
session_id=self._session.session_id,
|
|
415
|
+
kind="result", tool=name,
|
|
416
|
+
return_code=tool_result.return_code,
|
|
417
|
+
elapsed_ms=elapsed * 1000, decision="allowed",
|
|
418
|
+
))
|
|
419
|
+
return output
|
|
420
|
+
except Exception as e:
|
|
421
|
+
err = f"ERROR: {type(e).__name__}: {e}"
|
|
422
|
+
self._logger.tool_error_box(name, err)
|
|
423
|
+
self._total_tool_errors += 1
|
|
424
|
+
self._audit.record(AuditEvent(
|
|
425
|
+
ts=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
426
|
+
session_id=self._session.session_id,
|
|
427
|
+
kind="result", tool=name,
|
|
428
|
+
return_code=-1, decision="allowed",
|
|
429
|
+
))
|
|
430
|
+
return err
|
|
431
|
+
|
|
432
|
+
# ------------------------------------------------------------------ #
|
|
433
|
+
# Message building #
|
|
434
|
+
# ------------------------------------------------------------------ #
|
|
435
|
+
|
|
436
|
+
def _build_messages(self) -> list[dict]:
|
|
437
|
+
"""Build messages for the provider.
|
|
438
|
+
|
|
439
|
+
Ensures:
|
|
440
|
+
1. First message is always role=user
|
|
441
|
+
2. Last message is always role=user
|
|
442
|
+
3. Internal roles converted for API compatibility
|
|
443
|
+
4. No consecutive same-role messages
|
|
444
|
+
"""
|
|
445
|
+
messages = list(self._session.messages)
|
|
446
|
+
if not messages:
|
|
447
|
+
messages.append({"role": "user", "content": f"Goal: {self._session.goal}"})
|
|
448
|
+
|
|
449
|
+
if messages[0].get("role") != "user":
|
|
450
|
+
messages.insert(0, {"role": "user", "content": f"Goal: {self._session.goal}"})
|
|
451
|
+
|
|
452
|
+
if messages[-1].get("role") != "user":
|
|
453
|
+
messages.append({
|
|
454
|
+
"role": "user",
|
|
455
|
+
"content": "ดำเนินการต่อ ใช้ tools เพื่อทำงาน หรือส่ง <DONE> เมื่อเสร็จสิ้น",
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
# Convert internal roles
|
|
459
|
+
filtered = []
|
|
460
|
+
for m in messages:
|
|
461
|
+
role = m.get("role", "")
|
|
462
|
+
if role in ("user", "assistant", "system"):
|
|
463
|
+
filtered.append(m)
|
|
464
|
+
elif role == "tool_call":
|
|
465
|
+
name = m.get("name", "?")
|
|
466
|
+
args = m.get("args", {})
|
|
467
|
+
filtered.append({
|
|
468
|
+
"role": "assistant",
|
|
469
|
+
"content": f"[Tool Call: {name}] {json.dumps(args, default=str)[:200]}",
|
|
470
|
+
})
|
|
471
|
+
elif role == "tool_result":
|
|
472
|
+
content = m.get("content", "")
|
|
473
|
+
filtered.append({
|
|
474
|
+
"role": "user",
|
|
475
|
+
"content": f"[Tool Result] {content[:500]}",
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
# Merge consecutive same-role
|
|
479
|
+
merged: list[dict] = []
|
|
480
|
+
for m in filtered:
|
|
481
|
+
if merged and merged[-1]["role"] == m["role"]:
|
|
482
|
+
merged[-1]["content"] += "\n" + m.get("content", "")
|
|
483
|
+
else:
|
|
484
|
+
merged.append(dict(m))
|
|
485
|
+
|
|
486
|
+
return merged
|
|
487
|
+
|
|
488
|
+
# ------------------------------------------------------------------ #
|
|
489
|
+
# Stop / shutdown #
|
|
490
|
+
# ------------------------------------------------------------------ #
|
|
491
|
+
|
|
492
|
+
def _check_stop(self) -> str | None:
|
|
493
|
+
"""Only stop on interrupt or token limit.
|
|
494
|
+
|
|
495
|
+
No time limit, no step limit — the agent runs until:
|
|
496
|
+
1. Model emits <DONE>
|
|
497
|
+
2. User presses Ctrl+C (interrupted)
|
|
498
|
+
3. Token budget exceeded
|
|
499
|
+
4. Provider error (handled in the stream loop)
|
|
500
|
+
"""
|
|
501
|
+
if self._interrupted:
|
|
502
|
+
return "interrupted"
|
|
503
|
+
if self._session.estimated_tokens >= self._config.max_tokens:
|
|
504
|
+
return "token_limit"
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
def _replans_left(self) -> bool:
|
|
508
|
+
return self._replans_used < self._config.max_replans
|
|
509
|
+
|
|
510
|
+
def _graceful_shutdown(self, sig: int, frame: object) -> None:
|
|
511
|
+
if self._interrupted:
|
|
512
|
+
sys.exit(1)
|
|
513
|
+
self._interrupted = True
|
|
514
|
+
self._logger.warn("Interrupted — finishing current step...")
|
|
515
|
+
self._session.save()
|
|
516
|
+
|
|
517
|
+
@staticmethod
|
|
518
|
+
def _extract_summary(text: str) -> str:
|
|
519
|
+
cleaned = text.replace("<DONE>", "").strip()
|
|
520
|
+
# Remove text-based tool call artifacts
|
|
521
|
+
cleaned = re.sub(r'\[Tool Call:.*?\]\s*\{[^}]*\}', '', cleaned)
|
|
522
|
+
cleaned = cleaned.strip()
|
|
523
|
+
if len(cleaned) > 500:
|
|
524
|
+
return cleaned[:500] + "..."
|
|
525
|
+
return cleaned or "(no summary)"
|
|
526
|
+
|
|
527
|
+
# ------------------------------------------------------------------ #
|
|
528
|
+
# File tracking & cleanup #
|
|
529
|
+
# ------------------------------------------------------------------ #
|
|
530
|
+
|
|
531
|
+
def _snapshot_files(self) -> set[str]:
|
|
532
|
+
"""No-op — kept for compatibility. Cleanup now uses fixed /tmp/r3d."""
|
|
533
|
+
return set()
|
|
534
|
+
|
|
535
|
+
def _cleanup_files(self) -> None:
|
|
536
|
+
"""Remove agent workdir — all agent files live here."""
|
|
537
|
+
import shutil
|
|
538
|
+
from pathlib import Path
|
|
539
|
+
from r3d.tools.shell import AGENT_WORKDIR
|
|
540
|
+
|
|
541
|
+
workdir = AGENT_WORKDIR
|
|
542
|
+
if not workdir.exists():
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
files: list[str] = []
|
|
546
|
+
try:
|
|
547
|
+
for f in workdir.rglob("*"):
|
|
548
|
+
if f.is_file():
|
|
549
|
+
files.append(f.name)
|
|
550
|
+
except Exception:
|
|
551
|
+
pass
|
|
552
|
+
|
|
553
|
+
if not files:
|
|
554
|
+
return
|
|
555
|
+
|
|
556
|
+
try:
|
|
557
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
558
|
+
self._logger.success(f"🧹 ลบไฟล์ที่ agent สร้างใน {workdir}: {len(files)} ไฟล์")
|
|
559
|
+
for name in files[:10]:
|
|
560
|
+
self._logger.console.print(f" 🗑️ {name}", style="r3d.dim")
|
|
561
|
+
if len(files) > 10:
|
|
562
|
+
self._logger.console.print(
|
|
563
|
+
f" … และอีก {len(files) - 10} ไฟล์", style="r3d.dim"
|
|
564
|
+
)
|
|
565
|
+
except Exception as e:
|
|
566
|
+
self._logger.warn(f"ลบ {workdir} ไม่สำเร็จ: {e}")
|
|
567
|
+
|
|
568
|
+
# ------------------------------------------------------------------ #
|
|
569
|
+
# Text-based tool call parser #
|
|
570
|
+
# ------------------------------------------------------------------ #
|
|
571
|
+
|
|
572
|
+
@staticmethod
|
|
573
|
+
def _parse_text_tool_calls(text: str) -> list[tuple[str, str, dict]]:
|
|
574
|
+
"""Parse tool calls written as text by models without native function calling.
|
|
575
|
+
|
|
576
|
+
Recognizes patterns like:
|
|
577
|
+
[Tool Call: run_cmd] {"cmd": "nmap ...", "timeout": 30}
|
|
578
|
+
[tool_call: file] {"action": "read", "path": "test.txt"}
|
|
579
|
+
```json\n{"tool": "run_cmd", "cmd": "..."}\n```
|
|
580
|
+
|
|
581
|
+
Returns list of (id, name, args) tuples.
|
|
582
|
+
"""
|
|
583
|
+
calls: list[tuple[str, str, dict]] = []
|
|
584
|
+
call_id = 0
|
|
585
|
+
|
|
586
|
+
# Pattern 1: [Tool Call: name] {json}
|
|
587
|
+
pattern1 = re.compile(
|
|
588
|
+
r'\[Tool\s*Call:\s*(\w+)\]\s*(\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\})',
|
|
589
|
+
re.IGNORECASE | re.DOTALL,
|
|
590
|
+
)
|
|
591
|
+
for m in pattern1.finditer(text):
|
|
592
|
+
name = m.group(1).strip()
|
|
593
|
+
raw_json = m.group(2).strip()
|
|
594
|
+
try:
|
|
595
|
+
args = json.loads(raw_json)
|
|
596
|
+
call_id += 1
|
|
597
|
+
calls.append((f"text_call_{call_id}", name, args))
|
|
598
|
+
except json.JSONDecodeError:
|
|
599
|
+
continue
|
|
600
|
+
|
|
601
|
+
# Pattern 2: ```json {"tool": "name", ...} ``` or {"name": "run_cmd", "cmd": ...}
|
|
602
|
+
if not calls:
|
|
603
|
+
pattern2 = re.compile(
|
|
604
|
+
r'```(?:json)?\s*(\{[^`]+\})\s*```',
|
|
605
|
+
re.DOTALL,
|
|
606
|
+
)
|
|
607
|
+
for m in pattern2.finditer(text):
|
|
608
|
+
raw_json = m.group(1).strip()
|
|
609
|
+
try:
|
|
610
|
+
obj = json.loads(raw_json)
|
|
611
|
+
if isinstance(obj, dict):
|
|
612
|
+
name = obj.pop("tool", obj.pop("name", ""))
|
|
613
|
+
if name and isinstance(name, str):
|
|
614
|
+
call_id += 1
|
|
615
|
+
calls.append((f"text_call_{call_id}", name, obj))
|
|
616
|
+
except json.JSONDecodeError:
|
|
617
|
+
continue
|
|
618
|
+
|
|
619
|
+
# Pattern 3: Inline JSON with known tool names
|
|
620
|
+
if not calls:
|
|
621
|
+
known_tools = ("run_cmd", "file", "record_finding", "exploit",
|
|
622
|
+
"payload", "open_url", "web_search", "post")
|
|
623
|
+
for tool_name in known_tools:
|
|
624
|
+
# Match: run_cmd({"cmd": "..."}) or run_cmd {"cmd": "..."}
|
|
625
|
+
pat = re.compile(
|
|
626
|
+
rf'\b{tool_name}\s*\(?\s*(\{{[^{{}}]*(?:\{{[^{{}}]*\}}[^{{}}]*)*\}})\s*\)?',
|
|
627
|
+
re.DOTALL,
|
|
628
|
+
)
|
|
629
|
+
for m in pat.finditer(text):
|
|
630
|
+
raw_json = m.group(1).strip()
|
|
631
|
+
try:
|
|
632
|
+
args = json.loads(raw_json)
|
|
633
|
+
call_id += 1
|
|
634
|
+
calls.append((f"text_call_{call_id}", tool_name, args))
|
|
635
|
+
except json.JSONDecodeError:
|
|
636
|
+
continue
|
|
637
|
+
|
|
638
|
+
return calls
|