feat: backend orkestrasyonunu ve arac entegrasyonlarini genislet

This commit is contained in:
2026-03-22 04:45:43 +03:00
parent d07bc365f5
commit 5f4c19a18d
25 changed files with 3750 additions and 82 deletions

View File

@@ -1,18 +1,150 @@
import asyncio
from typing import Any
from app.tools.base import Tool
def _escape_applescript(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
def _body_to_notes_html(title: str, body: str) -> str:
if not body:
return title
html_body = body.replace("\n", "<br>")
return f"{title}<br><br>{html_body}"
class AppleNotesTool(Tool):
name = "apple_notes"
description = "Create notes in Apple Notes through AppleScript."
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
title = str(payload.get("title", "")).strip()
def parameters_schema(self) -> dict[str, Any]:
return {
"tool": self.name,
"status": "stub",
"title": title,
"message": "Apple Notes integration is not wired yet.",
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["create_note"],
"description": "The Apple Notes action to perform.",
},
"title": {
"type": "string",
"description": "Title for the new note.",
},
"body": {
"type": "string",
"description": "Optional body content for the note.",
},
"folder": {
"type": "string",
"description": "Optional Notes folder name. Defaults to Notes.",
},
},
"required": ["action", "title"],
"additionalProperties": False,
}
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
action = str(payload.get("action", "create_note")).strip()
title = str(payload.get("title", "")).strip()
body = str(payload.get("body", "")).strip()
folder = str(payload.get("folder", "Notes")).strip() or "Notes"
if action != "create_note":
return {
"tool": self.name,
"status": "error",
"message": f"Unsupported action: {action}",
}
if not title:
return {
"tool": self.name,
"status": "error",
"message": "title is required.",
}
note_html = _body_to_notes_html(title, body)
script = f'''
tell application "Notes"
activate
if not (exists folder "{_escape_applescript(folder)}") then
make new folder with properties {{name:"{_escape_applescript(folder)}"}}
end if
set targetFolder to folder "{_escape_applescript(folder)}"
set newNote to make new note at targetFolder with properties {{body:"{_escape_applescript(note_html)}"}}
return id of newNote
end tell
'''.strip()
created = await self._run_osascript(script)
if created["status"] != "ok":
return {
"tool": self.name,
"status": "error",
"action": action,
"title": title,
"folder": folder,
"message": created["message"],
}
note_id = created["stdout"]
verify_script = f'''
tell application "Notes"
set matchedNotes to every note of folder "{_escape_applescript(folder)}" whose id is "{_escape_applescript(note_id)}"
if (count of matchedNotes) is 0 then
return "NOT_FOUND"
end if
set matchedNote to item 1 of matchedNotes
return name of matchedNote
end tell
'''.strip()
verified = await self._run_osascript(verify_script)
if verified["status"] != "ok":
return {
"tool": self.name,
"status": "error",
"action": action,
"title": title,
"folder": folder,
"note_id": note_id,
"message": f'Note was created but could not be verified: {verified["message"]}',
}
verified_title = verified["stdout"]
if verified_title == "NOT_FOUND":
return {
"tool": self.name,
"status": "error",
"action": action,
"title": title,
"folder": folder,
"note_id": note_id,
"message": "Note was created but could not be found during verification.",
}
return {
"tool": self.name,
"status": "ok",
"action": action,
"title": title,
"body": body,
"folder": folder,
"note_id": note_id,
"verified_title": verified_title,
}
async def _run_osascript(self, script: str) -> dict[str, str]:
process = await asyncio.create_subprocess_exec(
"osascript",
"-e",
script,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
stdout_text = stdout.decode("utf-8", errors="replace").strip()
stderr_text = stderr.decode("utf-8", errors="replace").strip()
if process.returncode != 0:
return {"status": "error", "message": stderr_text or "AppleScript command failed.", "stdout": stdout_text}
return {"status": "ok", "message": "", "stdout": stdout_text}

View File

@@ -6,7 +6,19 @@ class Tool(ABC):
name: str
description: str
def definition(self) -> dict[str, Any]:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters_schema(),
},
}
def parameters_schema(self) -> dict[str, Any]:
return {"type": "object", "properties": {}}
@abstractmethod
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError

View File

@@ -1,3 +1,4 @@
import httpx
from typing import Any
from app.tools.base import Tool
@@ -7,12 +8,119 @@ class BraveSearchTool(Tool):
name = "brave_search"
description = "Search the web with Brave Search."
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
query = str(payload.get("query", "")).strip()
def __init__(self, api_key: str) -> None:
self.api_key = api_key
def parameters_schema(self) -> dict[str, Any]:
return {
"tool": self.name,
"status": "stub",
"query": query,
"message": "Brave Search integration is not wired yet.",
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The web search query.",
},
"count": {
"type": "integer",
"description": "Optional number of results from 1 to 10.",
"minimum": 1,
"maximum": 10,
},
"mode": {
"type": "string",
"description": "Search mode: web or images.",
"enum": ["web", "images"],
},
},
"required": ["query"],
"additionalProperties": False,
}
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
query = str(payload.get("query", "")).strip()
count = int(payload.get("count", 5) or 5)
count = max(1, min(10, count))
mode = str(payload.get("mode", "web") or "web").strip().lower()
if mode not in {"web", "images"}:
mode = "web"
if not query:
return {
"tool": self.name,
"status": "error",
"message": "Query is required.",
}
if not self.api_key:
return {
"tool": self.name,
"status": "error",
"query": query,
"message": "Brave Search API key is not configured.",
}
try:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.get(
"https://api.search.brave.com/res/v1/images/search"
if mode == "images"
else "https://api.search.brave.com/res/v1/web/search",
headers={
"Accept": "application/json",
"Accept-Encoding": "gzip",
"X-Subscription-Token": self.api_key,
},
params={
"q": query,
"count": count,
"search_lang": "en",
"country": "us",
},
)
response.raise_for_status()
except httpx.HTTPError as exc:
return {
"tool": self.name,
"status": "error",
"query": query,
"message": str(exc),
}
payload_json = response.json()
if mode == "images":
images = []
for item in payload_json.get("results", [])[:count]:
images.append(
{
"title": item.get("title", ""),
"url": item.get("url", ""),
"source": item.get("source", ""),
"thumbnail": item.get("thumbnail", {}).get("src", "") if isinstance(item.get("thumbnail"), dict) else "",
"properties_url": item.get("properties", {}).get("url", "") if isinstance(item.get("properties"), dict) else "",
}
)
return {
"tool": self.name,
"status": "ok",
"mode": mode,
"query": query,
"images": images,
"total_results": len(images),
}
results = []
for item in payload_json.get("web", {}).get("results", [])[:count]:
results.append(
{
"title": item.get("title", ""),
"url": item.get("url", ""),
"description": item.get("description", ""),
}
)
return {
"tool": self.name,
"status": "ok",
"mode": mode,
"query": query,
"results": results,
"total_results": len(results),
}

View File

@@ -0,0 +1,296 @@
import asyncio
import json
import os
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import httpx
from app.config import Settings
from app.models import RuntimeSettings
from app.tools.base import Tool
class BrowserUseTool(Tool):
name = "browser_use"
description = (
"Use the browser-use agent for higher-level real browser tasks such as navigating sites, "
"extracting lists, comparing items, and completing multi-step browsing workflows."
)
def __init__(self, workspace_root: Path, runtime: RuntimeSettings, settings: Settings, api_key: str) -> None:
self.workspace_root = workspace_root.resolve()
self.runtime = runtime
self.settings = settings
self.api_key = api_key
self.debug_port = 9223 + (abs(hash(str(self.workspace_root))) % 200)
self.chromium_path = (
Path.home()
/ "Library"
/ "Caches"
/ "ms-playwright"
/ "chromium-1194"
/ "chrome-mac"
/ "Chromium.app"
/ "Contents"
/ "MacOS"
/ "Chromium"
)
def parameters_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"task": {
"type": "string",
"description": "The high-level browser task to complete.",
},
"start_url": {
"type": "string",
"description": "Optional URL to open first before the agent starts.",
},
"max_steps": {
"type": "integer",
"description": "Maximum browser-use steps before stopping. Defaults to 20.",
},
"keep_alive": {
"type": "boolean",
"description": "Keep the browser open after the run finishes.",
},
"allowed_domains": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of allowed domains for the run.",
},
},
"required": ["task"],
"additionalProperties": False,
}
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
task = str(payload.get("task", "")).strip()
if not task:
return {"tool": self.name, "status": "error", "message": "task is required."}
start_url = str(payload.get("start_url", "")).strip()
max_steps = int(payload.get("max_steps", 20))
keep_alive = bool(payload.get("keep_alive", False))
allowed_domains = self._normalize_domains(payload.get("allowed_domains"))
if start_url and not allowed_domains:
host = urlparse(start_url).netloc
if host:
allowed_domains = [host]
llm_error = self._provider_readiness_error()
if llm_error is not None:
return {"tool": self.name, "status": "error", "message": llm_error}
try:
result = await self._run_agent(
task=self._compose_task(task, start_url),
max_steps=max_steps,
keep_alive=keep_alive,
allowed_domains=allowed_domains,
)
except Exception as exc:
return {
"tool": self.name,
"status": "error",
"message": str(exc),
}
return {
"tool": self.name,
"status": "ok" if result["success"] else "error",
**result,
}
async def _run_agent(
self,
task: str,
max_steps: int,
keep_alive: bool,
allowed_domains: list[str],
) -> dict[str, Any]:
from browser_use import Agent, Browser, ChatAnthropic, ChatOpenAI
cdp_url = await self._ensure_persistent_browser()
browser = Browser(
cdp_url=cdp_url,
is_local=True,
keep_alive=True,
allowed_domains=allowed_domains or None,
)
llm = self._build_llm(ChatAnthropic=ChatAnthropic, ChatOpenAI=ChatOpenAI)
agent = Agent(
task=task,
llm=llm,
browser=browser,
use_vision=True,
enable_planning=False,
max_actions_per_step=3,
display_files_in_done_text=False,
)
try:
history = await agent.run(max_steps=max_steps)
final_result = history.final_result() or ""
extracted = history.extracted_content()
errors = [error for error in history.errors() if error]
urls = [url for url in history.urls() if url]
return {
"success": bool(history.is_successful()),
"final_result": final_result,
"extracted_content": extracted[-10:],
"errors": errors[-5:],
"urls": urls[-10:],
"steps": history.number_of_steps(),
"actions": history.action_names()[-20:],
}
finally:
await agent.close()
def _build_llm(self, ChatAnthropic: Any, ChatOpenAI: Any) -> Any:
if self.runtime.model_provider == "zai":
return ChatAnthropic(
model=self.runtime.zai_model,
api_key=self.api_key,
base_url=self.settings.zai_base_url,
timeout=180.0,
)
return ChatOpenAI(
model=self.runtime.local_model,
api_key="lm-studio",
base_url=f"{self.runtime.local_base_url.rstrip('/')}/v1",
timeout=180.0,
)
def _provider_readiness_error(self) -> str | None:
if self.runtime.model_provider == "zai" and not self.api_key.strip():
return "Z.AI API key is not configured."
if self.runtime.model_provider == "local" and not self.runtime.local_base_url.strip():
return "Local model base URL is not configured."
return None
def _compose_task(self, task: str, start_url: str) -> str:
instructions = [
"Work in a real browser on macOS.",
"If the task asks for list extraction, return concise structured text.",
"If a captcha or login wall blocks progress, stop immediately and say that user action is required.",
"Do not click third-party sign-in buttons such as Google, Apple, or GitHub OAuth buttons.",
"Do not open or interact with login popups or OAuth consent windows.",
"If authentication is required, leave the page open in the persistent browser and tell the user to complete login manually, then retry the task.",
"Do not submit irreversible forms or purchases unless the user explicitly asked for it.",
]
if start_url:
instructions.append(f"Start at this URL first: {start_url}")
instructions.append(task)
return "\n".join(instructions)
def _normalize_domains(self, value: object) -> list[str]:
if not isinstance(value, list):
return []
return [str(item).strip() for item in value if str(item).strip()]
def _profile_root(self) -> Path:
profile_root = self.workspace_root / ".wiseclaw" / "browser-use-profile"
profile_root.mkdir(parents=True, exist_ok=True)
(profile_root / "WiseClaw").mkdir(parents=True, exist_ok=True)
return profile_root
async def _ensure_persistent_browser(self) -> str:
state = self._load_browser_state()
if state and self._pid_is_running(int(state.get("pid", 0))):
cdp_url = await self._fetch_cdp_url(int(state["port"]))
if cdp_url:
return cdp_url
await self._launch_persistent_browser()
cdp_url = await self._wait_for_cdp_url()
self._save_browser_state({"pid": self._read_pid_file(), "port": self.debug_port})
return cdp_url
async def _launch_persistent_browser(self) -> None:
executable = str(self.chromium_path if self.chromium_path.exists() else "Chromium")
profile_root = self._profile_root()
args = [
executable,
f"--remote-debugging-port={self.debug_port}",
f"--user-data-dir={profile_root}",
"--profile-directory=WiseClaw",
"--no-first-run",
"--no-default-browser-check",
"--start-maximized",
"about:blank",
]
process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
start_new_session=True,
)
self._write_pid_file(process.pid)
async def _wait_for_cdp_url(self) -> str:
for _ in range(40):
cdp_url = await self._fetch_cdp_url(self.debug_port)
if cdp_url:
return cdp_url
await asyncio.sleep(0.5)
raise RuntimeError("Persistent Chromium browser did not expose a CDP endpoint in time.")
async def _fetch_cdp_url(self, port: int) -> str:
try:
async with httpx.AsyncClient(timeout=2.0) as client:
response = await client.get(f"http://127.0.0.1:{port}/json/version")
response.raise_for_status()
except httpx.HTTPError:
return ""
payload = response.json()
return str(payload.get("webSocketDebuggerUrl", ""))
def _browser_state_path(self) -> Path:
return self.workspace_root / ".wiseclaw" / "browser-use-browser.json"
def _browser_pid_path(self) -> Path:
return self.workspace_root / ".wiseclaw" / "browser-use-browser.pid"
def _load_browser_state(self) -> dict[str, int] | None:
path = self._browser_state_path()
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return None
def _save_browser_state(self, payload: dict[str, int]) -> None:
path = self._browser_state_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
def _write_pid_file(self, pid: int) -> None:
path = self._browser_pid_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(str(pid), encoding="utf-8")
def _read_pid_file(self) -> int:
path = self._browser_pid_path()
if not path.exists():
return 0
try:
return int(path.read_text(encoding="utf-8").strip())
except ValueError:
return 0
def _pid_is_running(self, pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
except OSError:
return False
return True

View File

@@ -6,16 +6,100 @@ from app.tools.base import Tool
class FilesTool(Tool):
name = "files"
description = "Read and write files within allowed paths."
description = "Read, list, and write files within the workspace."
def __init__(self, workspace_root: Path) -> None:
self.workspace_root = workspace_root.resolve()
def parameters_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["read", "list", "write"],
"description": "Use read to read a file, list to list a directory, or write to create/update a file.",
},
"path": {
"type": "string",
"description": "Absolute or relative path inside the workspace.",
},
"content": {
"type": "string",
"description": "File content for write operations.",
},
},
"required": ["action", "path"],
"additionalProperties": False,
}
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
action = str(payload.get("action", "read")).strip()
path = Path(str(payload.get("path", "")).strip()).expanduser()
raw_path = str(payload.get("path", "")).strip()
path = self._resolve_path(raw_path)
if action == "read":
if not path.exists():
return {"tool": self.name, "status": "error", "message": f"Path not found: {path}"}
if path.is_dir():
return {"tool": self.name, "status": "error", "message": f"Path is a directory: {path}"}
content = path.read_text(encoding="utf-8", errors="replace")
return {
"tool": self.name,
"status": "ok",
"action": action,
"path": str(path),
"content": content[:12000],
"truncated": len(content) > 12000,
}
if action == "list":
if not path.exists():
return {"tool": self.name, "status": "error", "message": f"Path not found: {path}"}
if not path.is_dir():
return {"tool": self.name, "status": "error", "message": f"Path is not a directory: {path}"}
entries = []
for child in sorted(path.iterdir(), key=lambda item: item.name.lower())[:200]:
entries.append(
{
"name": child.name,
"type": "dir" if child.is_dir() else "file",
}
)
return {
"tool": self.name,
"status": "ok",
"action": action,
"path": str(path),
"entries": entries,
"truncated": len(entries) >= 200,
}
if action == "write":
content = str(payload.get("content", ""))
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return {
"tool": self.name,
"status": "ok",
"action": action,
"path": str(path),
"bytes_written": len(content.encode("utf-8")),
}
return {
"tool": self.name,
"status": "stub",
"action": action,
"path": str(path),
"message": "File integration is not wired yet.",
"status": "error",
"message": f"Unsupported action: {action}. Allowed actions are read, list, and write.",
}
def _resolve_path(self, raw_path: str) -> Path:
candidate = Path(raw_path).expanduser()
if not candidate.is_absolute():
candidate = (self.workspace_root / candidate).resolve()
else:
candidate = candidate.resolve()
if self.workspace_root not in candidate.parents and candidate != self.workspace_root:
raise ValueError(f"Path is outside the workspace: {candidate}")
return candidate

View File

@@ -0,0 +1,47 @@
from pathlib import Path
from sqlalchemy.orm import Session
from app.config import get_settings
from app.db import SecretORM
from app.models import RuntimeSettings
from app.tools.apple_notes import AppleNotesTool
from app.tools.browser_use import BrowserUseTool
from app.tools.brave_search import BraveSearchTool
from app.tools.files import FilesTool
from app.tools.second_brain import SecondBrainTool
from app.tools.terminal import TerminalTool
from app.tools.web_fetch import WebFetchTool
def build_tools(runtime: RuntimeSettings, workspace_root: Path, session: Session) -> dict[str, object]:
enabled = {tool.name for tool in runtime.tools if tool.enabled}
tools: dict[str, object] = {}
settings = get_settings()
if "files" in enabled:
tools["files"] = FilesTool(workspace_root)
if "apple_notes" in enabled:
tools["apple_notes"] = AppleNotesTool()
if "browser_use" in enabled:
secret = session.get(SecretORM, "zai_api_key")
api_key = secret.value if secret else settings.zai_api_key
tools["browser_use"] = BrowserUseTool(workspace_root, runtime, settings, api_key)
if "brave_search" in enabled and runtime.search_provider == "brave":
secret = session.get(SecretORM, "brave_api_key")
api_key = secret.value if secret else settings.brave_api_key
tools["brave_search"] = BraveSearchTool(api_key)
if "second_brain" in enabled:
secret = session.get(SecretORM, "anythingllm_api_key")
api_key = secret.value if secret else settings.anythingllm_api_key
tools["second_brain"] = SecondBrainTool(
base_url=runtime.anythingllm_base_url,
workspace_slug=runtime.anythingllm_workspace_slug,
api_key=api_key,
)
if "web_fetch" in enabled:
tools["web_fetch"] = WebFetchTool()
if "terminal" in enabled:
tools["terminal"] = TerminalTool(runtime.terminal_mode, workspace_root)
return tools

View File

@@ -0,0 +1,164 @@
from typing import Any
import httpx
from app.tools.base import Tool
class SecondBrainTool(Tool):
name = "second_brain"
description = "Search and retrieve context from the configured AnythingLLM workspace."
def __init__(self, base_url: str, workspace_slug: str, api_key: str) -> None:
self.base_url = base_url.rstrip("/")
self.workspace_slug = workspace_slug.strip().strip("/")
self.api_key = api_key.strip()
def parameters_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The user question to search in the second brain workspace.",
},
"mode": {
"type": "string",
"description": "Workspace chat mode. Prefer query for retrieval-focused lookups.",
"enum": ["query", "chat"],
},
},
"required": ["query"],
"additionalProperties": False,
}
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
query = str(payload.get("query", "")).strip()
mode = str(payload.get("mode", "query") or "query").strip().lower()
if mode not in {"query", "chat"}:
mode = "query"
if not query:
return {"tool": self.name, "status": "error", "message": "Query is required."}
if not self.base_url:
return {"tool": self.name, "status": "error", "message": "AnythingLLM base URL is not configured."}
if not self.workspace_slug:
return {"tool": self.name, "status": "error", "message": "AnythingLLM workspace slug is not configured."}
if not self.api_key:
return {"tool": self.name, "status": "error", "message": "AnythingLLM API key is not configured."}
endpoint = f"{self.base_url}/api/v1/workspace/{self.workspace_slug}/chat"
instructed_query = self._build_query_prompt(query, mode)
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload_candidates = [
{
"message": instructed_query,
"mode": mode,
"sessionId": None,
"attachments": [],
},
{
"message": instructed_query,
"mode": "chat",
"sessionId": None,
"attachments": [],
},
{
"message": instructed_query,
"mode": "chat",
},
]
last_error = ""
response = None
try:
async with httpx.AsyncClient(timeout=30.0) as client:
for request_payload in payload_candidates:
response = await client.post(endpoint, headers=headers, json=request_payload)
if response.is_success:
break
last_error = self._format_error(response)
if response.status_code != 400:
response.raise_for_status()
else:
return {
"tool": self.name,
"status": "error",
"query": query,
"workspace_slug": self.workspace_slug,
"message": last_error or "AnythingLLM request failed.",
}
except httpx.HTTPError as exc:
return {
"tool": self.name,
"status": "error",
"query": query,
"workspace_slug": self.workspace_slug,
"message": str(exc),
}
data = response.json() if response is not None else {}
text_response = self._extract_text_response(data)
sources = self._extract_sources(data)
return {
"tool": self.name,
"status": "ok",
"query": query,
"mode": mode,
"workspace_slug": self.workspace_slug,
"context": text_response,
"sources": sources,
"raw": data,
}
def _build_query_prompt(self, query: str, mode: str) -> str:
if mode == "query":
return (
"Only answer the exact question using the workspace context. "
"Do not add commentary, headings, bullets, extra notes, names, or related reminders. "
"If the answer contains a date and place, return only that information in one short sentence. "
"Question: "
f"{query}"
)
return query
def _format_error(self, response: httpx.Response) -> str:
try:
payload = response.json()
except ValueError:
return f"HTTP {response.status_code}"
if isinstance(payload, dict):
for key in ("error", "message"):
value = payload.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return f"HTTP {response.status_code}"
def _extract_text_response(self, data: Any) -> str:
if isinstance(data, dict):
for key in ("textResponse", "response", "answer", "text", "message"):
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _extract_sources(self, data: Any) -> list[dict[str, str]]:
if not isinstance(data, dict):
return []
raw_sources = data.get("sources", [])
if not isinstance(raw_sources, list):
return []
sources: list[dict[str, str]] = []
for item in raw_sources[:6]:
if not isinstance(item, dict):
continue
sources.append(
{
"title": str(item.get("title") or item.get("source") or item.get("url") or "").strip(),
"url": str(item.get("url") or "").strip(),
"snippet": str(item.get("text") or item.get("snippet") or item.get("description") or "").strip(),
}
)
return sources

View File

@@ -1,3 +1,6 @@
import asyncio
import subprocess
from pathlib import Path
from typing import Any
from app.security import evaluate_terminal_command
@@ -8,17 +11,115 @@ class TerminalTool(Tool):
name = "terminal"
description = "Run terminal commands under WiseClaw policy."
def __init__(self, terminal_mode: int) -> None:
def __init__(self, terminal_mode: int, workspace_root: Path) -> None:
self.terminal_mode = terminal_mode
self.workspace_root = workspace_root.resolve()
def parameters_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "A single shell command. Only safe approved prefixes run automatically.",
},
"background": {
"type": "boolean",
"description": "Run the command in the background for long-lived local servers.",
},
"workdir": {
"type": "string",
"description": "Optional relative workspace directory for the command.",
},
},
"required": ["command"],
"additionalProperties": False,
}
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
command = str(payload.get("command", "")).strip()
background = bool(payload.get("background", False))
workdir = self._resolve_workdir(str(payload.get("workdir", "")).strip()) if payload.get("workdir") else self.workspace_root
decision = evaluate_terminal_command(command, self.terminal_mode)
if decision.decision != "allow":
return {
"tool": self.name,
"status": "approval_required" if decision.decision == "approval" else "blocked",
"command": command,
"decision": decision.decision,
"reason": decision.reason,
}
if background:
return self._run_background(command, decision.reason, workdir)
try:
process = await asyncio.create_subprocess_shell(
command,
cwd=str(workdir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0)
except TimeoutError:
return {
"tool": self.name,
"status": "error",
"command": command,
"decision": decision.decision,
"reason": "Command timed out after 15 seconds.",
}
stdout_text = stdout.decode("utf-8", errors="replace")
stderr_text = stderr.decode("utf-8", errors="replace")
return {
"tool": self.name,
"status": "stub",
"status": "ok" if process.returncode == 0 else "error",
"command": command,
"decision": decision.decision,
"reason": decision.reason,
"workdir": str(workdir),
"exit_code": process.returncode,
"stdout": stdout_text[:12000],
"stderr": stderr_text[:12000],
"stdout_truncated": len(stdout_text) > 12000,
"stderr_truncated": len(stderr_text) > 12000,
}
def _run_background(self, command: str, reason: str, workdir: Path) -> dict[str, Any]:
logs_dir = self.workspace_root / ".wiseclaw" / "logs"
logs_dir.mkdir(parents=True, exist_ok=True)
log_path = logs_dir / f"terminal-{abs(hash((command, str(workdir))))}.log"
log_handle = log_path.open("ab")
process = subprocess.Popen(
command,
cwd=str(workdir),
shell=True,
stdout=log_handle,
stderr=subprocess.STDOUT,
start_new_session=True,
)
log_handle.close()
return {
"tool": self.name,
"status": "ok",
"command": command,
"decision": "allow",
"reason": reason,
"workdir": str(workdir),
"background": True,
"pid": process.pid,
"log_path": str(log_path),
}
def _resolve_workdir(self, raw_path: str) -> Path:
candidate = Path(raw_path).expanduser()
if not candidate.is_absolute():
candidate = (self.workspace_root / candidate).resolve()
else:
candidate = candidate.resolve()
if self.workspace_root not in candidate.parents and candidate != self.workspace_root:
raise ValueError(f"Workdir is outside the workspace: {candidate}")
if not candidate.exists() or not candidate.is_dir():
raise ValueError(f"Workdir is not a directory: {candidate}")
return candidate

View File

@@ -1,5 +1,8 @@
import re
from typing import Any
import httpx
from app.tools.base import Tool
@@ -7,12 +10,56 @@ class WebFetchTool(Tool):
name = "web_fetch"
description = "Fetch a webpage and return simplified content."
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
url = str(payload.get("url", "")).strip()
def parameters_schema(self) -> dict[str, Any]:
return {
"tool": self.name,
"status": "stub",
"url": url,
"message": "Web fetch integration is not wired yet.",
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The http or https URL to fetch.",
}
},
"required": ["url"],
"additionalProperties": False,
}
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
url = str(payload.get("url", "")).strip()
if not url.startswith(("http://", "https://")):
return {
"tool": self.name,
"status": "error",
"url": url,
"message": "Only http and https URLs are allowed.",
}
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
response = await client.get(url)
response.raise_for_status()
except httpx.HTTPError as exc:
return {
"tool": self.name,
"status": "error",
"url": url,
"message": str(exc),
}
text = self._simplify_content(response.text)
return {
"tool": self.name,
"status": "ok",
"url": url,
"content_type": response.headers.get("content-type", ""),
"content": text[:12000],
"truncated": len(text) > 12000,
}
def _simplify_content(self, content: str) -> str:
text = re.sub(r"(?is)<script.*?>.*?</script>", " ", content)
text = re.sub(r"(?is)<style.*?>.*?</style>", " ", text)
text = re.sub(r"(?s)<[^>]+>", " ", text)
text = re.sub(r"&nbsp;", " ", text)
text = re.sub(r"&amp;", "&", text)
text = re.sub(r"\s+", " ", text)
return text.strip()