"""ReAct stili tool cagri ayriciligi + dispatcher.
Akis (genel)
------------
1. Model ureten bir metin doner. Genellikle su sablon:
Thought: hedefin portlarini taramaliyim.
Action: run_cmd
Action Input: nmap -sV -p- 10.0.0.5
ya da bitirirken:
Thought: yeterli bilgi var.
Final Answer: Acik portlar: 22/ssh, 80/http ...
2. `parse_react()` bu metni `ParsedResponse`'a cevirir.
3. agent.py birden fazla aksiyon varsa kullaniciya sorar (proje karari).
4. `ToolDispatcher.dispatch(call)` -> safety -> KaliSSH -> Observation string.
5. Observation modele "Observation: ..." prefix'i ile geri verilir.
Bu modul sadece parsing ve dispatch'i yapar; LLM cagrisi agent.py'da.
Modelfile uyumu
---------------
Modelfile'da `Observation:` ve `Tool Output:` stop token'lari var. Bu sayede
model `Action Input: ...` yazdiktan sonra durur ve biz observation'i
ekleyene kadar uretmez. parse_react bu sayede tek seferde bir aksiyona
odaklanir.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Callable, Optional
from safety import SafetyChecker, Verdict
from ssh_client import KaliSSH, SSHConnectionError
if TYPE_CHECKING:
from logger import SessionLogger
# -----------------------------------------------------------------------------
# Veri tipleri
# -----------------------------------------------------------------------------
@dataclass
class ToolCall:
"""Model tarafindan istenen tek bir tool cagrisi."""
tool: str
args: dict[str, str]
raw: str = "" # ham metin (debug)
def short(self, n: int = 120) -> str:
body = self.args.get("cmd") or json.dumps(self.args, ensure_ascii=False)
body = body.replace("\n", " ")
return f"{self.tool}({body if len(body) <= n else body[: n - 3] + '...'})"
@dataclass
class ParsedResponse:
"""Modelin tek bir uretiminden cikarilmis yapisal bilgi."""
thought: Optional[str]
actions: list[ToolCall] = field(default_factory=list)
final_answer: Optional[str] = None
raw: str = ""
@property
def has_action(self) -> bool:
return bool(self.actions)
@property
def is_final(self) -> bool:
return self.final_answer is not None and not self.actions
# -----------------------------------------------------------------------------
# Parser
# -----------------------------------------------------------------------------
# Bir bolumun bittigini gosteren etiketler. Lookahead'de kullanilir.
_SECTION_LOOKAHEAD = (
r"(?=\n\s*(?:Thought\s*:|Action\s*:|Action\s*Input\s*:|"
r"Observation\s*:|Tool\s*Output\s*:|Final\s*Answer\s*:)|\Z)"
)
_THOUGHT_RE = re.compile(
r"Thought\s*:\s*(?P
.*?)" + _SECTION_LOOKAHEAD,
re.DOTALL | re.IGNORECASE,
)
# Klasik ReAct format:
# Action: run_cmd
# Action Input: nmap -sV 1.2.3.4
_ACTION_BLOCK_RE = re.compile(
r"Action\s*:\s*(?P[A-Za-z_][\w\-]*)\s*\n"
r"\s*Action\s*Input\s*:\s*(?P.*?)" + _SECTION_LOOKAHEAD,
re.DOTALL | re.IGNORECASE,
)
# Fonksiyon-cagri stili (model bu sekilde de yazabilir):
# Action: run_cmd(nmap -sV 1.2.3.4)
# Parantez ici icerigi tolere etmek icin nested 1 seviye paren'e izin veriyoruz.
# Onemli: bu regex \n veya '(' ile baslayan satirlari yakalamali, klasik
# formati da yanlis match etmesin diye lookahead ile parantez sart.
_ACTION_FUNCTION_RE = re.compile(
r"Action\s*:\s*(?P[A-Za-z_][\w\-]*)\s*"
r"\(\s*(?P(?:[^()]|\([^()]*\))*?)\s*\)",
re.DOTALL | re.IGNORECASE,
)
_FINAL_RE = re.compile(
r"Final\s*Answer\s*:\s*(?P.*?)\Z",
re.DOTALL | re.IGNORECASE,
)
def _strip_input(raw: str) -> str:
"""Action Input'u temizle: kod blogu / tirnak isaretlerini sok."""
text = raw.strip()
if not text:
return ""
# ```bash\n...\n``` veya ```\n...\n```
fenced = re.match(r"^```(?:[a-zA-Z0-9_+-]*)\s*\n(?P.*?)\n```\s*$", text, re.DOTALL)
if fenced:
text = fenced.group("body").strip()
return text
# `tek-satir`
if text.startswith("`") and text.endswith("`") and text.count("`") == 2:
text = text[1:-1].strip()
return text
# "cift tirnak" veya 'tek tirnak' (sarmalayici)
if (text.startswith('"') and text.endswith('"')) or (
text.startswith("'") and text.endswith("'")
):
text = text[1:-1].strip()
return text
def parse_react(text: str) -> ParsedResponse:
"""ReAct stilinde uretilmis metni yapisal hale getir.
Notlar:
* Birden fazla Action blogu varsa hepsi listeye girer.
* Final Answer varsa actions yine de toplanir; agent.py karari verir.
* Buyuk/kucuk harf duyarsiz; etiketlerin once gelen bosluklari onemsiz.
"""
raw = text or ""
thought_match = _THOUGHT_RE.search(raw)
thought = thought_match.group("body").strip() if thought_match else None
# Iki farkli format'i da yakaliyoruz; pozisyona gore sirala ki model'in
# uretim sirasi korunsun. Ayni pozisyonda klasik bulunduysa fonksiyon
# cagrisi versiyonunu yoksay (cunku zaten klasik match ediyor demektir).
raw_matches: list[tuple[int, str, str, str]] = [] # (pos, tool, input, raw_text)
classic_spans: list[tuple[int, int]] = []
for m in _ACTION_BLOCK_RE.finditer(raw):
classic_spans.append((m.start(), m.end()))
raw_matches.append(
(m.start(), m.group("tool").strip(), _strip_input(m.group("input")), m.group(0))
)
def _overlaps_classic(start: int, end: int) -> bool:
return any(not (end <= s or start >= e) for s, e in classic_spans)
for m in _ACTION_FUNCTION_RE.finditer(raw):
if _overlaps_classic(m.start(), m.end()):
continue
raw_matches.append(
(m.start(), m.group("tool").strip(), _strip_input(m.group("input")), m.group(0))
)
raw_matches.sort(key=lambda t: t[0])
actions: list[ToolCall] = [
ToolCall(tool=tool, args={"cmd": cmd_input}, raw=raw_text)
for _, tool, cmd_input, raw_text in raw_matches
]
final_match = _FINAL_RE.search(raw)
final_answer = final_match.group("body").strip() if final_match else None
# Final Answer eger ayni cikti icinde Action'dan SONRA geliyorsa, ReAct
# mantiginda Action'i once isleriz. Bu yuzden eger actions varsa
# final_answer'i bu turda yoksayariz; agent.py loop'u bir sonraki turda
# alir. Aksi durumda final_answer dogru kalir.
if actions and final_answer is not None:
# Eger final_answer son Action'dan sonra geliyorsa, bos olmadigi
# surece bilgi olarak tutalim ama is_final dondermeyiz.
all_action_ends = [
m.end() for m in _ACTION_BLOCK_RE.finditer(raw)
] + [
m.end() for m in _ACTION_FUNCTION_RE.finditer(raw)
]
last_action_end = max(all_action_ends) if all_action_ends else 0
final_pos = raw.rfind("Final Answer")
if final_pos < last_action_end:
final_answer = None
return ParsedResponse(
thought=thought,
actions=actions,
final_answer=final_answer,
raw=raw,
)
# -----------------------------------------------------------------------------
# Dispatcher
# -----------------------------------------------------------------------------
ToolFn = Callable[[dict[str, str]], str]
class UnknownToolError(RuntimeError):
pass
class ToolDispatcher:
"""ToolCall -> Observation string. SafetyChecker araligi burada."""
def __init__(
self,
kali: KaliSSH,
safety: SafetyChecker,
*,
logger: Optional["SessionLogger"] = None,
):
self.kali = kali
self.safety = safety
self.logger = logger
self._tools: dict[str, ToolFn] = {
"run_cmd": self._tool_run_cmd,
}
@property
def available_tools(self) -> tuple[str, ...]:
return tuple(sorted(self._tools.keys()))
def register(self, name: str, fn: ToolFn) -> None:
self._tools[name] = fn
def dispatch(self, call: ToolCall) -> str:
if self.logger:
self.logger.tool_call(call.tool, call.args)
fn = self._tools.get(call.tool)
if fn is None:
msg = (
f"BLOCKED: bilinmeyen tool '{call.tool}'. "
f"Kullanilabilir: {', '.join(self.available_tools)}"
)
if self.logger:
self.logger.error("unknown_tool", tool=call.tool)
return msg
try:
return fn(call.args)
except Exception as exc: # noqa: BLE001
if self.logger:
self.logger.error("tool_exception", tool=call.tool, detail=str(exc))
return f"ERROR: tool '{call.tool}' istisna firlatti: {exc}"
# --- tools ----------------------------------------------------------
def _tool_run_cmd(self, args: dict[str, str]) -> str:
cmd = (args.get("cmd") or "").strip()
if not cmd:
return "BLOCKED: bos komut."
verdict: Verdict = self.safety.check(cmd)
if verdict.blocked:
# Modele net mesaj donelim ki yeniden dene-mesin / siklasmasin.
return verdict.to_user_message()
try:
result = self.kali.run(cmd)
except SSHConnectionError as exc:
return f"ERROR: SSH/komut hatasi: {exc}"
body = result.combined_output()
prefix = ""
if verdict.severity == "warn":
prefix = verdict.to_user_message() + "\n"
return f"{prefix}exit={result.exit_code} ({result.duration_s:.1f}s)\n{body}"
# -----------------------------------------------------------------------------
# Yardimci: observation'i model girdisine donustur
# -----------------------------------------------------------------------------
def format_observation(obs: str) -> str:
"""Modele Observation: olarak verilecek bicimi olustur.
`Observation:` Modelfile'da stop token oldugu icin model bunu yazmaya
calismaz; bizim eklememiz gerekir. Sonuna bos satir koymak modelin
devam etme niyetini desteklemek icin.
"""
return f"Observation: {obs.rstrip()}\n"
# -----------------------------------------------------------------------------
# Self-test
# -----------------------------------------------------------------------------
def _self_test() -> int:
samples = [
# 0: tek action
(
"tek_action",
"Thought: portlari taramaliyim.\n"
"Action: run_cmd\n"
"Action Input: nmap -sV 10.0.0.1\n",
{"actions": 1, "final": False, "tool": "run_cmd",
"cmd": "nmap -sV 10.0.0.1"},
),
# 1: kod bloklu input
(
"kod_bloklu",
"Action: run_cmd\n"
"Action Input: ```bash\nnmap -sV 10.0.0.1\n```\n",
{"actions": 1, "cmd": "nmap -sV 10.0.0.1"},
),
# 2: tirnakli input
(
"tirnakli",
'Action: run_cmd\nAction Input: "whoami"\n',
{"actions": 1, "cmd": "whoami"},
),
# 3: final answer
(
"final",
"Thought: bitti.\nFinal Answer: 22/ssh, 80/http acik.\n",
{"actions": 0, "final": True},
),
# 4: coklu action
(
"coklu_action",
"Action: run_cmd\nAction Input: whoami\n"
"Action: run_cmd\nAction Input: hostname\n",
{"actions": 2},
),
# 5: action sonrasi final (agent loop bir sonraki tura biraktirir)
(
"action_sonra_final",
"Action: run_cmd\nAction Input: ls\nFinal Answer: tamam\n",
{"actions": 1, "final": False}, # is_final False olmali
),
# 6: fonksiyon-cagri stili (model bu sekilde de yazabilir)
(
"fonksiyon_cagri",
"Thought: scan.\nAction: run_cmd(nmap -p- 1.2.3.4 | grep open)\n",
{"actions": 1, "tool": "run_cmd",
"cmd": "nmap -p- 1.2.3.4 | grep open"},
),
# 7: fonksiyon-cagri + final (action calismali, final_answer yoksayilir)
(
"fonksiyon_sonra_final",
"Action: run_cmd(whoami)\nFinal Answer: ben kullaniciyim\n",
{"actions": 1, "final": False, "cmd": "whoami"},
),
# 8: ic parantezli komut (process substitution)
(
"ic_paren",
"Action: run_cmd(echo $(date) > /tmp/x)\n",
{"actions": 1, "cmd": "echo $(date) > /tmp/x"},
),
]
failed = 0
for name, text, expected in samples:
p = parse_react(text)
ok = True
if "actions" in expected and len(p.actions) != expected["actions"]:
ok = False
if "tool" in expected and (not p.actions or p.actions[0].tool != expected["tool"]):
ok = False
if "cmd" in expected and (not p.actions or p.actions[0].args.get("cmd") != expected["cmd"]):
ok = False
if "final" in expected and bool(p.is_final) != bool(expected["final"]):
ok = False
marker = "OK " if ok else "FAIL"
print(f"[{marker}] {name:<20} actions={len(p.actions)} "
f"final={p.is_final} thought={'Y' if p.thought else 'N'}")
if not ok:
failed += 1
print(f" beklenen: {expected}")
print(f" bulunan : actions={[a.short() for a in p.actions]} "
f"final={p.final_answer!r}")
print()
print(f"{len(samples) - failed}/{len(samples)} parse testi gecti.")
return 0 if failed == 0 else 1
if __name__ == "__main__":
import sys
sys.exit(_self_test())