feat: backend orkestrasyonunu ve arac entegrasyonlarini genislet
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
296
backend/app/tools/browser_use.py
Normal file
296
backend/app/tools/browser_use.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
47
backend/app/tools/registry.py
Normal file
47
backend/app/tools/registry.py
Normal 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
|
||||
164
backend/app/tools/second_brain.py
Normal file
164
backend/app/tools/second_brain.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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" ", " ", text)
|
||||
text = re.sub(r"&", "&", text)
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text.strip()
|
||||
|
||||
Reference in New Issue
Block a user