Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Runtime-managed native subagent tools.
|
||||
|
||||
These tools are intercepted by Native Runtime V2 rather than executed by the
|
||||
global registry. Their schemas must still be exposed to the model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from opc.layer4_tools.registry import ToolDefinition
|
||||
|
||||
|
||||
async def _runtime_noop(**_kwargs: Any) -> dict[str, Any]:
|
||||
return {"error": "runtime managed tool must be intercepted by Native Runtime V2", "success": False}
|
||||
|
||||
|
||||
def create_agent_runtime_tools() -> list[ToolDefinition]:
|
||||
shared_profile_desc = "Specialist profile to use: general | explore | plan | implement | verify."
|
||||
return [
|
||||
ToolDefinition(
|
||||
name="agent_spawn",
|
||||
description=(
|
||||
"Spawn a native subagent. Use explore/plan for read-only investigation, "
|
||||
"implement for isolated coding, and verify for adversarial validation. "
|
||||
"Supports OpenOPC native subagent fields such as description, name, model, "
|
||||
"background, mode, and worktree isolation."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Short description of the subagent task",
|
||||
},
|
||||
"profile": {"type": "string", "description": shared_profile_desc},
|
||||
"prompt": {"type": "string", "description": "Task for the subagent"},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional stable nickname for addressing the subagent later",
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Optional model override for this subagent",
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"description": "Permission mode hint: default | plan | accept_edits | bypass_permissions | dont_ask",
|
||||
"default": "default",
|
||||
},
|
||||
"background": {"type": "boolean", "description": "Run in background", "default": False},
|
||||
"resident": {
|
||||
"type": "boolean",
|
||||
"description": "Keep a background worker resident so it can resume with follow-up input after going idle.",
|
||||
"default": False,
|
||||
},
|
||||
"isolation": {
|
||||
"type": "string",
|
||||
"description": "Isolation mode: shared | worktree",
|
||||
"default": "",
|
||||
},
|
||||
},
|
||||
"required": ["profile", "prompt"],
|
||||
},
|
||||
func=_runtime_noop,
|
||||
category="orchestration",
|
||||
concurrency_safe=False,
|
||||
read_only=False,
|
||||
runtime_managed=True,
|
||||
),
|
||||
ToolDefinition(
|
||||
name="agent_wait",
|
||||
description="Wait for a previously spawned subagent to complete and return its latest result.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {"type": "string", "description": "Subagent id returned by agent_spawn"},
|
||||
"timeout_seconds": {
|
||||
"type": "integer",
|
||||
"description": "Maximum time to wait before returning still-running status",
|
||||
"default": 300,
|
||||
},
|
||||
},
|
||||
"required": ["agent_id"],
|
||||
},
|
||||
func=_runtime_noop,
|
||||
category="orchestration",
|
||||
concurrency_safe=False,
|
||||
read_only=True,
|
||||
runtime_managed=True,
|
||||
),
|
||||
ToolDefinition(
|
||||
name="agent_send",
|
||||
description="Send a follow-up message to a running native subagent.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {"type": "string", "description": "Subagent id returned by agent_spawn"},
|
||||
"message": {"type": "string", "description": "Follow-up instruction for the subagent"},
|
||||
},
|
||||
"required": ["agent_id", "message"],
|
||||
},
|
||||
func=_runtime_noop,
|
||||
category="orchestration",
|
||||
concurrency_safe=False,
|
||||
read_only=False,
|
||||
runtime_managed=True,
|
||||
),
|
||||
ToolDefinition(
|
||||
name="agent_list",
|
||||
description="List currently known native subagents and their status.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
func=_runtime_noop,
|
||||
category="orchestration",
|
||||
concurrency_safe=True,
|
||||
read_only=True,
|
||||
runtime_managed=True,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,727 @@
|
||||
"""Native Playwright-backed browser tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from opc.core.config import OPCConfig, get_opc_home
|
||||
from opc.layer4_tools.output_budget import clip_text
|
||||
from opc.layer4_tools.registry import ToolDefinition
|
||||
|
||||
try:
|
||||
from playwright.async_api import Error as PlaywrightError
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
||||
from playwright.async_api import async_playwright
|
||||
except Exception: # pragma: no cover - exercised via install-hint tests
|
||||
PlaywrightError = RuntimeError
|
||||
PlaywrightTimeoutError = TimeoutError
|
||||
async_playwright = None
|
||||
|
||||
|
||||
_INSTALL_HINT = (
|
||||
"Browser tools require the optional Playwright dependency. "
|
||||
"Install it with `pip install -e .[browser]` and then run "
|
||||
"`python -m playwright install chromium`."
|
||||
)
|
||||
_EMBEDDED_BROWSER_ARGS = ("--disable-dev-shm-usage", "--no-sandbox")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserLaunchConfig:
|
||||
mode: str = "embedded"
|
||||
headless: bool = True
|
||||
chrome_channel: str = "chrome"
|
||||
chrome_executable_path: str = ""
|
||||
user_data_dir: str = ""
|
||||
args: tuple[str, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> "BrowserLaunchConfig":
|
||||
config_dir = get_opc_home() / "config"
|
||||
if not config_dir.is_dir():
|
||||
return cls()
|
||||
config = OPCConfig.load(config_dir)
|
||||
browser = getattr(config.system, "browser", None)
|
||||
if browser is None:
|
||||
return cls()
|
||||
return cls(
|
||||
mode=str(browser.mode or "embedded").strip().lower(),
|
||||
headless=bool(browser.headless),
|
||||
chrome_channel=str(browser.chrome_channel or "").strip(),
|
||||
chrome_executable_path=str(browser.chrome_executable_path or "").strip(),
|
||||
user_data_dir=str(browser.user_data_dir or "").strip(),
|
||||
args=tuple(str(arg).strip() for arg in (browser.args or []) if str(arg).strip()),
|
||||
)
|
||||
|
||||
|
||||
class BrowserRuntime:
|
||||
"""Single-browser runtime shared by native browser tools."""
|
||||
|
||||
def __init__(self, config_loader: Callable[[], BrowserLaunchConfig] | None = None) -> None:
|
||||
self._playwright: Any = None
|
||||
self._browser: Any = None
|
||||
self._context: Any = None
|
||||
self._page: Any = None
|
||||
self._lock = asyncio.Lock()
|
||||
self._config_loader = config_loader or BrowserLaunchConfig.load
|
||||
self._launch_config: BrowserLaunchConfig | None = None
|
||||
|
||||
async def navigate(self, url: str, wait_until: str = "domcontentloaded") -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
page = await self._ensure_page()
|
||||
await page.goto(url, wait_until=wait_until, timeout=30_000)
|
||||
try:
|
||||
await page.wait_for_load_state("networkidle", timeout=5_000)
|
||||
except PlaywrightTimeoutError:
|
||||
pass
|
||||
return await self._build_snapshot(page, max_chars=6_000)
|
||||
|
||||
async def snapshot(self, filename: str | None = None, max_chars: int = 12_000) -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
page = await self._require_page()
|
||||
snapshot = await self._build_snapshot(page, max_chars=max_chars)
|
||||
if filename:
|
||||
path = self._resolve_output_path(filename, suffix=".md")
|
||||
path.write_text(self._snapshot_to_markdown(snapshot), encoding="utf-8")
|
||||
snapshot["saved_to"] = str(path)
|
||||
return snapshot
|
||||
|
||||
async def click(self, selector: str) -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
page = await self._require_page()
|
||||
locator = await self._resolve_locator(page, selector)
|
||||
await locator.click(timeout=10_000)
|
||||
try:
|
||||
await page.wait_for_load_state("networkidle", timeout=5_000)
|
||||
except PlaywrightTimeoutError:
|
||||
pass
|
||||
return await self._build_snapshot(page, max_chars=4_000)
|
||||
|
||||
async def type(
|
||||
self,
|
||||
selector: str,
|
||||
text: str,
|
||||
press_enter: bool = False,
|
||||
clear_existing: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
page = await self._require_page()
|
||||
locator = await self._resolve_locator(page, selector)
|
||||
if clear_existing:
|
||||
await locator.fill(text, timeout=10_000)
|
||||
else:
|
||||
await locator.click(timeout=10_000)
|
||||
await locator.type(text, timeout=10_000)
|
||||
if press_enter:
|
||||
await locator.press("Enter")
|
||||
return await self._build_snapshot(page, max_chars=4_000)
|
||||
|
||||
async def wait_for(
|
||||
self,
|
||||
selector: str | None = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
state: str = "visible",
|
||||
) -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
page = await self._require_page()
|
||||
timeout_ms = max(100, int(timeout_seconds * 1000))
|
||||
if selector:
|
||||
await page.wait_for_selector(selector, state=state, timeout=timeout_ms)
|
||||
else:
|
||||
await page.wait_for_load_state(state if state in {"load", "domcontentloaded", "networkidle"} else "networkidle", timeout=timeout_ms)
|
||||
return await self._build_snapshot(page, max_chars=4_000)
|
||||
|
||||
async def scroll(
|
||||
self,
|
||||
amount: int = 800,
|
||||
direction: str = "down",
|
||||
to_bottom: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
page = await self._require_page()
|
||||
if to_bottom:
|
||||
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||
else:
|
||||
delta = abs(int(amount or 0))
|
||||
if direction.strip().lower() == "up":
|
||||
delta = -delta
|
||||
await page.evaluate(f"window.scrollBy(0, {delta})")
|
||||
try:
|
||||
await page.wait_for_load_state("networkidle", timeout=3_000)
|
||||
except PlaywrightTimeoutError:
|
||||
pass
|
||||
return await self._build_snapshot(page, max_chars=4_000)
|
||||
|
||||
async def select_option(
|
||||
self,
|
||||
selector: str,
|
||||
value: str | None = None,
|
||||
label: str | None = None,
|
||||
index: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
page = await self._require_page()
|
||||
locator = await self._resolve_locator(page, selector)
|
||||
option: str | dict[str, Any]
|
||||
if index is not None:
|
||||
option = {"index": int(index)}
|
||||
elif label is not None:
|
||||
option = {"label": label}
|
||||
elif value is not None:
|
||||
option = value
|
||||
else:
|
||||
raise RuntimeError("Provide at least one of `value`, `label`, or `index`.")
|
||||
await locator.select_option(option, timeout=10_000)
|
||||
return await self._build_snapshot(page, max_chars=4_000)
|
||||
|
||||
async def navigate_back(self) -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
page = await self._require_page()
|
||||
previous = await page.go_back(wait_until="domcontentloaded", timeout=15_000)
|
||||
if previous is None:
|
||||
raise RuntimeError("No previous page in browser history.")
|
||||
try:
|
||||
await page.wait_for_load_state("networkidle", timeout=5_000)
|
||||
except PlaywrightTimeoutError:
|
||||
pass
|
||||
return await self._build_snapshot(page, max_chars=6_000)
|
||||
|
||||
async def evaluate(self, expression: str, selector: str | None = None) -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
page = await self._require_page()
|
||||
if selector:
|
||||
locator = await self._resolve_locator(page, selector)
|
||||
result = await locator.evaluate(expression)
|
||||
else:
|
||||
result = await page.evaluate(expression)
|
||||
return {
|
||||
"url": page.url,
|
||||
"title": await page.title(),
|
||||
"result": result,
|
||||
}
|
||||
|
||||
async def take_screenshot(
|
||||
self,
|
||||
filename: str | None = None,
|
||||
full_page: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
page = await self._require_page()
|
||||
path = self._resolve_output_path(filename, suffix=".png")
|
||||
await page.screenshot(path=str(path), full_page=full_page)
|
||||
return {
|
||||
"saved_to": str(path),
|
||||
"url": page.url,
|
||||
"title": await page.title(),
|
||||
}
|
||||
|
||||
async def close(self) -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
await self._reset()
|
||||
return {"closed": True}
|
||||
|
||||
async def _ensure_page(self) -> Any:
|
||||
self._ensure_dependency()
|
||||
launch_config = self._config_loader()
|
||||
if self._page is not None:
|
||||
if self._launch_config == launch_config:
|
||||
return self._page
|
||||
await self._reset()
|
||||
try:
|
||||
self._playwright = await async_playwright().start()
|
||||
self._browser, self._context, self._page = await self._launch_browser(launch_config)
|
||||
self._launch_config = launch_config
|
||||
return self._page
|
||||
except Exception as exc:
|
||||
await self._reset()
|
||||
raise RuntimeError(self._format_launch_error(exc, launch_config)) from exc
|
||||
|
||||
async def _launch_browser(self, launch_config: BrowserLaunchConfig) -> tuple[Any, Any, Any]:
|
||||
mode = (launch_config.mode or "embedded").strip().lower()
|
||||
if mode == "chrome":
|
||||
return await self._launch_local_chrome(launch_config)
|
||||
if mode == "auto":
|
||||
chrome_error: Exception | None = None
|
||||
try:
|
||||
return await self._launch_local_chrome(launch_config)
|
||||
except Exception as exc:
|
||||
chrome_error = exc
|
||||
try:
|
||||
return await self._launch_embedded_browser(launch_config)
|
||||
except Exception as embedded_exc:
|
||||
raise RuntimeError(
|
||||
f"Auto mode could not launch local Chrome ({chrome_error}) "
|
||||
f"or embedded Chromium ({embedded_exc})."
|
||||
) from embedded_exc
|
||||
return await self._launch_embedded_browser(launch_config)
|
||||
|
||||
async def _launch_embedded_browser(self, launch_config: BrowserLaunchConfig) -> tuple[Any, Any, Any]:
|
||||
browser = await self._playwright.chromium.launch(
|
||||
headless=launch_config.headless,
|
||||
args=self._embedded_launch_args(launch_config),
|
||||
)
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
return browser, context, page
|
||||
|
||||
async def _launch_local_chrome(self, launch_config: BrowserLaunchConfig) -> tuple[Any, Any, Any]:
|
||||
launch_kwargs: dict[str, Any] = {"headless": launch_config.headless}
|
||||
if launch_config.args:
|
||||
launch_kwargs["args"] = list(launch_config.args)
|
||||
executable_path = self._normalize_executable_path(launch_config.chrome_executable_path)
|
||||
user_data_dir = self._normalize_user_data_dir(launch_config.user_data_dir)
|
||||
if executable_path:
|
||||
launch_kwargs["executable_path"] = executable_path
|
||||
else:
|
||||
launch_kwargs["channel"] = launch_config.chrome_channel or "chrome"
|
||||
if user_data_dir:
|
||||
context = await self._playwright.chromium.launch_persistent_context(
|
||||
user_data_dir=user_data_dir,
|
||||
ignore_https_errors=True,
|
||||
**launch_kwargs,
|
||||
)
|
||||
pages = list(getattr(context, "pages", []) or [])
|
||||
page = pages[0] if pages else await context.new_page()
|
||||
browser = getattr(context, "browser", None)
|
||||
return browser, context, page
|
||||
browser = await self._playwright.chromium.launch(**launch_kwargs)
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
return browser, context, page
|
||||
|
||||
def _embedded_launch_args(self, launch_config: BrowserLaunchConfig) -> list[str]:
|
||||
args = list(_EMBEDDED_BROWSER_ARGS)
|
||||
for item in launch_config.args:
|
||||
if item not in args:
|
||||
args.append(item)
|
||||
return args
|
||||
|
||||
def _normalize_executable_path(self, raw_path: str) -> str:
|
||||
raw = (raw_path or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
return str(Path(raw).expanduser())
|
||||
|
||||
def _normalize_user_data_dir(self, raw_path: str) -> str:
|
||||
raw = (raw_path or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
path = Path(raw).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = get_opc_home().parent / path
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return str(path)
|
||||
|
||||
def _format_launch_error(self, exc: Exception, launch_config: BrowserLaunchConfig) -> str:
|
||||
if (launch_config.mode or "").strip().lower() == "chrome":
|
||||
return (
|
||||
f"Failed to start local Chrome browser: {exc}. "
|
||||
"Check `system.browser.chrome_executable_path`, `system.browser.user_data_dir`, "
|
||||
"or switch `system.browser.mode` back to `embedded`."
|
||||
)
|
||||
return f"Failed to start browser runtime: {exc}. {_INSTALL_HINT}"
|
||||
|
||||
async def _require_page(self) -> Any:
|
||||
if self._page is None:
|
||||
raise RuntimeError('No open browser page. Use `browser_navigate` first.')
|
||||
return self._page
|
||||
|
||||
async def _resolve_locator(self, page: Any, selector: str) -> Any:
|
||||
raw = selector.strip()
|
||||
if not raw:
|
||||
raise RuntimeError("Selector must not be empty.")
|
||||
try:
|
||||
locator = page.locator(raw).first
|
||||
count = await locator.count()
|
||||
except PlaywrightError as exc:
|
||||
raise RuntimeError(f"Invalid selector `{raw}`: {exc}") from exc
|
||||
if count < 1:
|
||||
raise RuntimeError(f"No element matched selector `{raw}`.")
|
||||
return locator
|
||||
|
||||
async def _build_snapshot(self, page: Any, max_chars: int) -> dict[str, Any]:
|
||||
payload = await page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const normalize = (value) => (value || "").replace(/\\s+/g, " ").trim();
|
||||
const clip = (value, limit) => {
|
||||
const text = normalize(value);
|
||||
return text.length > limit ? text.slice(0, limit) + "..." : text;
|
||||
};
|
||||
const makeSelector = (el) => {
|
||||
const tag = (el.tagName || "div").toLowerCase();
|
||||
if (el.id) return `#${CSS.escape(el.id)}`;
|
||||
const name = el.getAttribute("name");
|
||||
if (name) return `${tag}[name="${name.replace(/"/g, '\\"')}"]`;
|
||||
const aria = el.getAttribute("aria-label");
|
||||
if (aria) return `${tag}[aria-label="${aria.replace(/"/g, '\\"')}"]`;
|
||||
const placeholder = el.getAttribute("placeholder");
|
||||
if (placeholder) return `${tag}[placeholder="${placeholder.replace(/"/g, '\\"')}"]`;
|
||||
const text = clip(el.innerText || el.textContent, 60);
|
||||
if (text && (tag === "button" || tag === "a")) {
|
||||
return `${tag}:has-text("${text.replace(/"/g, '\\"')}")`;
|
||||
}
|
||||
return tag;
|
||||
};
|
||||
|
||||
const interactive = Array.from(
|
||||
document.querySelectorAll('a, button, input, textarea, select, [role="button"]')
|
||||
)
|
||||
.filter((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style && style.display !== "none" && style.visibility !== "hidden";
|
||||
})
|
||||
.slice(0, 40)
|
||||
.map((el) => ({
|
||||
tag: (el.tagName || "").toLowerCase(),
|
||||
text: clip(el.innerText || el.textContent, 120),
|
||||
type: el.getAttribute("type") || "",
|
||||
role: el.getAttribute("role") || "",
|
||||
placeholder: el.getAttribute("placeholder") || "",
|
||||
selector: makeSelector(el),
|
||||
}));
|
||||
|
||||
const headings = Array.from(document.querySelectorAll("h1, h2, h3"))
|
||||
.slice(0, 12)
|
||||
.map((el) => clip(el.innerText || el.textContent, 160))
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
title: document.title || "",
|
||||
url: location.href,
|
||||
text: clip(document.body ? document.body.innerText : "", 50000),
|
||||
headings,
|
||||
interactive,
|
||||
};
|
||||
}
|
||||
"""
|
||||
)
|
||||
text = str(payload.get("text", "") or "")
|
||||
clip = clip_text(text, limit=max_chars, marker="browser snapshot text truncated") if max_chars > 0 else None
|
||||
if clip is not None:
|
||||
text = clip.text
|
||||
return {
|
||||
"title": str(payload.get("title", "") or ""),
|
||||
"url": str(payload.get("url", "") or page.url),
|
||||
"text": text,
|
||||
"text_truncated": bool(clip.truncated) if clip is not None else False,
|
||||
"text_omitted_chars": int(clip.omitted_chars) if clip is not None else 0,
|
||||
"headings": list(payload.get("headings", []) or []),
|
||||
"interactive_elements": list(payload.get("interactive", []) or []),
|
||||
}
|
||||
|
||||
def _snapshot_to_markdown(self, snapshot: dict[str, Any]) -> str:
|
||||
parts = [
|
||||
f"# {snapshot.get('title') or 'Browser Snapshot'}",
|
||||
"",
|
||||
f"- URL: {snapshot.get('url', '')}",
|
||||
"",
|
||||
"## Page Text",
|
||||
"",
|
||||
str(snapshot.get("text", "") or ""),
|
||||
]
|
||||
interactive = snapshot.get("interactive_elements", []) or []
|
||||
if interactive:
|
||||
parts.extend(["", "## Interactive Elements", ""])
|
||||
for item in interactive:
|
||||
parts.append(
|
||||
f"- `{item.get('selector', '')}` "
|
||||
f"[{item.get('tag', '')}] {item.get('text', '')}"
|
||||
)
|
||||
return "\n".join(parts).strip() + "\n"
|
||||
|
||||
def _resolve_output_path(self, filename: str | None, *, suffix: str) -> Path:
|
||||
if filename:
|
||||
path = Path(filename)
|
||||
if not path.suffix:
|
||||
path = path.with_suffix(suffix)
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
else:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
path = get_opc_home() / "artifacts" / "browser" / f"browser-{stamp}{suffix}"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def _ensure_dependency(self) -> None:
|
||||
if async_playwright is None:
|
||||
raise RuntimeError(_INSTALL_HINT)
|
||||
|
||||
async def _reset(self) -> None:
|
||||
page, context, browser, playwright = self._page, self._context, self._browser, self._playwright
|
||||
self._page = None
|
||||
self._context = None
|
||||
self._browser = None
|
||||
self._playwright = None
|
||||
self._launch_config = None
|
||||
if page is not None:
|
||||
try:
|
||||
await page.close()
|
||||
except Exception:
|
||||
pass
|
||||
if context is not None:
|
||||
try:
|
||||
await context.close()
|
||||
except Exception:
|
||||
pass
|
||||
if browser is not None:
|
||||
try:
|
||||
await browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
if playwright is not None:
|
||||
try:
|
||||
await playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_browser_runtime = BrowserRuntime()
|
||||
|
||||
|
||||
async def browser_navigate(url: str, wait_until: str = "domcontentloaded") -> dict[str, Any]:
|
||||
return await _browser_runtime.navigate(url=url, wait_until=wait_until)
|
||||
|
||||
|
||||
async def browser_snapshot(filename: str | None = None, max_chars: int = 12_000) -> dict[str, Any]:
|
||||
return await _browser_runtime.snapshot(filename=filename, max_chars=max_chars)
|
||||
|
||||
|
||||
async def browser_click(selector: str) -> dict[str, Any]:
|
||||
return await _browser_runtime.click(selector=selector)
|
||||
|
||||
|
||||
async def browser_type(
|
||||
selector: str,
|
||||
text: str,
|
||||
press_enter: bool = False,
|
||||
clear_existing: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
return await _browser_runtime.type(
|
||||
selector=selector,
|
||||
text=text,
|
||||
press_enter=press_enter,
|
||||
clear_existing=clear_existing,
|
||||
)
|
||||
|
||||
|
||||
async def browser_take_screenshot(
|
||||
filename: str | None = None,
|
||||
full_page: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
return await _browser_runtime.take_screenshot(filename=filename, full_page=full_page)
|
||||
|
||||
|
||||
async def browser_wait_for(
|
||||
selector: str | None = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
state: str = "visible",
|
||||
) -> dict[str, Any]:
|
||||
return await _browser_runtime.wait_for(selector=selector, timeout_seconds=timeout_seconds, state=state)
|
||||
|
||||
|
||||
async def browser_scroll(
|
||||
amount: int = 800,
|
||||
direction: str = "down",
|
||||
to_bottom: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
return await _browser_runtime.scroll(amount=amount, direction=direction, to_bottom=to_bottom)
|
||||
|
||||
|
||||
async def browser_select_option(
|
||||
selector: str,
|
||||
value: str | None = None,
|
||||
label: str | None = None,
|
||||
index: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await _browser_runtime.select_option(selector=selector, value=value, label=label, index=index)
|
||||
|
||||
|
||||
async def browser_navigate_back() -> dict[str, Any]:
|
||||
return await _browser_runtime.navigate_back()
|
||||
|
||||
|
||||
async def browser_close() -> dict[str, Any]:
|
||||
return await _browser_runtime.close()
|
||||
|
||||
|
||||
def create_browser_tools() -> list[ToolDefinition]:
|
||||
return [
|
||||
ToolDefinition(
|
||||
name="browser_navigate",
|
||||
description="Open a page in the configured local browser and return a text snapshot.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "URL to open"},
|
||||
"wait_until": {
|
||||
"type": "string",
|
||||
"description": "Playwright wait condition",
|
||||
"default": "domcontentloaded",
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
func=browser_navigate,
|
||||
category="browser",
|
||||
),
|
||||
ToolDefinition(
|
||||
name="browser_snapshot",
|
||||
description="Return the current page title, text, and interactive elements. Optionally save the snapshot to a markdown file.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"description": "Optional markdown file path to save the snapshot",
|
||||
},
|
||||
"max_chars": {
|
||||
"type": "integer",
|
||||
"description": "Maximum page text characters to return",
|
||||
"default": 12000,
|
||||
},
|
||||
},
|
||||
},
|
||||
func=browser_snapshot,
|
||||
category="browser",
|
||||
),
|
||||
ToolDefinition(
|
||||
name="browser_click",
|
||||
description="Click an element on the current page using a Playwright selector.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector": {"type": "string", "description": "Playwright selector for the target element"},
|
||||
},
|
||||
"required": ["selector"],
|
||||
},
|
||||
func=browser_click,
|
||||
category="browser",
|
||||
),
|
||||
ToolDefinition(
|
||||
name="browser_type",
|
||||
description="Type or fill text into a page element using a Playwright selector.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector": {"type": "string", "description": "Playwright selector for the target element"},
|
||||
"text": {"type": "string", "description": "Text to enter"},
|
||||
"press_enter": {
|
||||
"type": "boolean",
|
||||
"description": "Press Enter after typing",
|
||||
"default": False,
|
||||
},
|
||||
"clear_existing": {
|
||||
"type": "boolean",
|
||||
"description": "Replace existing content instead of appending",
|
||||
"default": True,
|
||||
},
|
||||
},
|
||||
"required": ["selector", "text"],
|
||||
},
|
||||
func=browser_type,
|
||||
category="browser",
|
||||
),
|
||||
ToolDefinition(
|
||||
name="browser_take_screenshot",
|
||||
description="Save a screenshot of the current page.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filename": {"type": "string", "description": "Optional screenshot path"},
|
||||
"full_page": {
|
||||
"type": "boolean",
|
||||
"description": "Capture the full scrollable page",
|
||||
"default": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
func=browser_take_screenshot,
|
||||
category="browser",
|
||||
),
|
||||
ToolDefinition(
|
||||
name="browser_wait_for",
|
||||
description="Wait for a selector or page load state before continuing.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "Optional selector to wait for. If omitted, waits for page load state.",
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "number",
|
||||
"description": "Maximum wait time in seconds",
|
||||
"default": 10.0,
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "Selector state (attached/visible/hidden/detached) or load state (load/domcontentloaded/networkidle)",
|
||||
"default": "visible",
|
||||
},
|
||||
},
|
||||
},
|
||||
func=browser_wait_for,
|
||||
category="browser",
|
||||
),
|
||||
ToolDefinition(
|
||||
name="browser_scroll",
|
||||
description="Scroll the current page up or down, or jump to the bottom.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"amount": {
|
||||
"type": "integer",
|
||||
"description": "Scroll distance in pixels",
|
||||
"default": 800,
|
||||
},
|
||||
"direction": {
|
||||
"type": "string",
|
||||
"description": "Scroll direction: down or up",
|
||||
"default": "down",
|
||||
},
|
||||
"to_bottom": {
|
||||
"type": "boolean",
|
||||
"description": "Jump directly to the bottom of the page",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
func=browser_scroll,
|
||||
category="browser",
|
||||
),
|
||||
ToolDefinition(
|
||||
name="browser_select_option",
|
||||
description="Choose an option in a select element by value, label, or index.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector": {"type": "string", "description": "Selector for the <select> element"},
|
||||
"value": {"type": "string", "description": "Option value to choose"},
|
||||
"label": {"type": "string", "description": "Visible label to choose"},
|
||||
"index": {"type": "integer", "description": "Zero-based option index"},
|
||||
},
|
||||
"required": ["selector"],
|
||||
},
|
||||
func=browser_select_option,
|
||||
category="browser",
|
||||
),
|
||||
ToolDefinition(
|
||||
name="browser_navigate_back",
|
||||
description="Go back to the previous page in browser history.",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
func=browser_navigate_back,
|
||||
category="browser",
|
||||
),
|
||||
ToolDefinition(
|
||||
name="browser_close",
|
||||
description="Close the active local browser session and clear in-memory page state.",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
func=browser_close,
|
||||
category="browser",
|
||||
),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,599 @@
|
||||
"""Runtime dispatcher for the ``opc-collab`` CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
from opc.core.company_tools import (
|
||||
COLLAB_PROFILE_DISABLED,
|
||||
resolve_allowed_collaboration_tools,
|
||||
resolve_task_collaboration_tools,
|
||||
)
|
||||
from opc.core.events import EventBus
|
||||
from opc.core.models import AgentMessage, MessageUrgency
|
||||
from opc.database.store import OPCStore
|
||||
from opc.layer2_organization.collaboration_service import (
|
||||
CollaborationContext,
|
||||
CollaborationService,
|
||||
)
|
||||
from opc.layer2_organization.communication import CommunicationManager
|
||||
from opc.layer2_organization.work_item_links import set_linked_work_item_id
|
||||
from opc.layer4_tools.collaboration import create_collaboration_tools
|
||||
|
||||
|
||||
Handler = Callable[[dict[str, Any], Optional[Mapping[str, str]]], Awaitable[dict[str, Any]]]
|
||||
BoundHandler = Callable[[dict[str, Any], "CollaborationRuntimeBinding"], Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CollaborationRuntimeBinding:
|
||||
service: CollaborationService
|
||||
context: CollaborationContext
|
||||
store: OPCStore | None
|
||||
manager: CommunicationManager
|
||||
env: Mapping[str, str] | None = None
|
||||
allowed_tools: set[str] | None = None
|
||||
owns_store: bool = False
|
||||
|
||||
|
||||
def _env(name: str, default: str = "", env: Mapping[str, str] | None = None) -> str:
|
||||
source = env if env is not None else os.environ
|
||||
return str(source.get(name, default))
|
||||
|
||||
|
||||
def _parse_allowed_tools(raw: str) -> set[str]:
|
||||
payload = str(raw or "").strip()
|
||||
if not payload:
|
||||
return set()
|
||||
try:
|
||||
parsed = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
parsed = [item.strip() for item in payload.split(",") if item.strip()]
|
||||
if isinstance(parsed, list):
|
||||
return {str(item).strip() for item in parsed if str(item).strip()}
|
||||
return set()
|
||||
|
||||
|
||||
async def build_collaboration_runtime() -> tuple[
|
||||
CollaborationService,
|
||||
CollaborationContext,
|
||||
OPCStore | None,
|
||||
CommunicationManager,
|
||||
]:
|
||||
return await build_collaboration_runtime_from_env(None)
|
||||
|
||||
|
||||
async def build_collaboration_runtime_from_env(
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> tuple[
|
||||
CollaborationService,
|
||||
CollaborationContext,
|
||||
OPCStore | None,
|
||||
CommunicationManager,
|
||||
]:
|
||||
"""Build the collaboration service/context from the current process env."""
|
||||
store: OPCStore | None = None
|
||||
task = None
|
||||
db_path = _env("OPC_PROJECT_DB_PATH", env=env)
|
||||
task_id = _env("OPC_TASK_ID", env=env) or _env("OPC_RUNTIME_TASK_ID", env=env)
|
||||
if db_path:
|
||||
store = OPCStore(db_path)
|
||||
await store.initialize(run_startup_maintenance=False)
|
||||
manager = CommunicationManager(store, EventBus())
|
||||
if task_id:
|
||||
task = await store.get_task(task_id)
|
||||
else:
|
||||
manager = CommunicationManager(None, EventBus())
|
||||
|
||||
from_role = _env("OPC_COMMS_FROM", env=env)
|
||||
context = (
|
||||
CollaborationContext.from_task(task, role_id=from_role)
|
||||
if task is not None
|
||||
else CollaborationContext.from_environment(
|
||||
role_id=from_role,
|
||||
project_id=_env("OPC_COMMS_PROJECT", "default", env=env),
|
||||
session_id=_env("OPC_COMMS_SESSION", "default", env=env),
|
||||
workspace_root=_env("OPC_WORKSPACE_ROOT", env=env) or _env("OPC_COMMS_ROOT", env=env) or os.getcwd(),
|
||||
task_id=task_id,
|
||||
)
|
||||
)
|
||||
work_item_id = _env("OPC_WORK_ITEM_ID", env=env)
|
||||
if work_item_id and task is not None:
|
||||
set_linked_work_item_id(task, work_item_id)
|
||||
if work_item_id and "linked_work_item_id" not in context.metadata:
|
||||
context.metadata["linked_work_item_id"] = work_item_id
|
||||
service = CollaborationService(manager)
|
||||
return service, context, store, manager
|
||||
|
||||
|
||||
async def build_collaboration_runtime_binding_from_env(
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> CollaborationRuntimeBinding:
|
||||
service, context, store, manager = await build_collaboration_runtime_from_env(env)
|
||||
return CollaborationRuntimeBinding(
|
||||
service=service,
|
||||
context=context,
|
||||
store=store,
|
||||
manager=manager,
|
||||
env=env,
|
||||
allowed_tools=allowed_tool_names(task=context.task, context=context, manager=manager, env=env),
|
||||
owns_store=store is not None,
|
||||
)
|
||||
|
||||
|
||||
def allowed_tool_names(
|
||||
*,
|
||||
task: Any,
|
||||
context: CollaborationContext,
|
||||
manager: CommunicationManager,
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> set[str]:
|
||||
explicit = _parse_allowed_tools(_env("OPC_ALLOWED_COLLAB_TOOLS", env=env))
|
||||
if explicit:
|
||||
return explicit
|
||||
role_cfg = None
|
||||
org_engine = getattr(manager, "org_engine", None)
|
||||
if org_engine is not None and context.role_id:
|
||||
try:
|
||||
role_cfg = org_engine.get_agent(context.role_id)
|
||||
except Exception:
|
||||
role_cfg = None
|
||||
profile = str(_env("OPC_COLLAB_PROFILE", env=env) or "").strip() or resolve_task_collaboration_tools(
|
||||
task,
|
||||
role=context.role_id,
|
||||
seat=str(getattr(task, "metadata", {}).get("delegation_seat_id", "") or "").strip()
|
||||
if task is not None
|
||||
else "",
|
||||
runtime_state={
|
||||
"manager_board_summary": (
|
||||
dict(getattr(task, "context_snapshot", {}).get("manager_board_summary", {}) or {})
|
||||
if task is not None
|
||||
else {}
|
||||
),
|
||||
},
|
||||
role_cfg=role_cfg,
|
||||
debug_admin=str(_env("OPC_MAILBOX_MODE", env=env) or "").strip().lower() == "debug_admin",
|
||||
)[0]
|
||||
if profile == COLLAB_PROFILE_DISABLED:
|
||||
return set()
|
||||
return resolve_allowed_collaboration_tools(profile, task=task, runtime_state={})
|
||||
|
||||
|
||||
def _simple_message_payload(message: dict[str, Any]) -> dict[str, Any]:
|
||||
metadata = dict(message.get("metadata", {}) or {})
|
||||
return {
|
||||
"msg_id": str(message.get("msg_id", "") or message.get("message_id", "")).strip(),
|
||||
"from_agent": str(message.get("from_agent", "") or message.get("from", "")).strip(),
|
||||
"subject": str(message.get("subject", "")).strip(),
|
||||
"body": str(message.get("body", "")).strip(),
|
||||
"reply_needed": bool(message.get("reply_needed", False)),
|
||||
"urgency": str(message.get("urgency", "") or "normal").strip() or "normal",
|
||||
"transport_kind": str(message.get("transport_kind", "") or metadata.get("transport_kind", "")).strip(),
|
||||
"semantic_type": str(message.get("semantic_type", "") or metadata.get("semantic_type", "")).strip(),
|
||||
"status": str(message.get("status", "") or "").strip(),
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
|
||||
async def _run_bound_handler(
|
||||
handler: BoundHandler,
|
||||
args: dict[str, Any],
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
binding = await build_collaboration_runtime_binding_from_env(env)
|
||||
try:
|
||||
return await handler(args, binding)
|
||||
finally:
|
||||
if binding.owns_store and binding.store is not None:
|
||||
await binding.store.close()
|
||||
|
||||
|
||||
def _env_handler(handler: BoundHandler) -> Handler:
|
||||
async def _handler(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
|
||||
return await _run_bound_handler(handler, args, env)
|
||||
|
||||
return _handler
|
||||
|
||||
|
||||
async def _handle_inbox_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
|
||||
service = binding.service
|
||||
context = binding.context
|
||||
if context.task is None:
|
||||
return {"error": "`inbox` requires an active task context."}
|
||||
raw_ids = args.get("message_ids", [])
|
||||
if isinstance(raw_ids, str):
|
||||
message_ids = [raw_ids]
|
||||
else:
|
||||
message_ids = [str(item).strip() for item in list(raw_ids or []) if str(item).strip()]
|
||||
return await service.inbox(
|
||||
context,
|
||||
agent_id=context.role_id,
|
||||
task=context.task,
|
||||
action=str(args.get("action", "status") or "status").strip(),
|
||||
message_ids=message_ids,
|
||||
limit=int(args.get("limit", 10) or 10),
|
||||
)
|
||||
|
||||
|
||||
async def _handle_inbox(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
|
||||
return await _run_bound_handler(_handle_inbox_bound, args, env)
|
||||
|
||||
|
||||
async def _handle_send_dm_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
|
||||
if "blocking" in args:
|
||||
return {"error": "`send_dm` no longer accepts `blocking`; use `ask_peer_and_wait`."}
|
||||
context = binding.context
|
||||
manager = binding.manager
|
||||
tool = next(tool for tool in create_collaboration_tools(manager) if tool.name == "send_dm")
|
||||
result = await tool.func(task=context.task, **dict(args or {}))
|
||||
return result if isinstance(result, dict) else {"result": result}
|
||||
|
||||
|
||||
async def _handle_send_dm(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
|
||||
return await _run_bound_handler(_handle_send_dm_bound, args, env)
|
||||
|
||||
|
||||
async def _handle_ask_peer_and_wait_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
|
||||
service = binding.service
|
||||
context = binding.context
|
||||
if context.task is None:
|
||||
return {"error": "`ask_peer_and_wait` requires an active task context."}
|
||||
return await service.ask_peer_and_wait(
|
||||
context,
|
||||
task=context.task,
|
||||
to_agent=str(args.get("to_agent", "")).strip(),
|
||||
subject=str(args.get("subject", "")).strip(),
|
||||
body=str(args.get("body", "")).strip(),
|
||||
timeout_action=str(args.get("timeout_action", "")).strip(),
|
||||
timeout_seconds=int(args.get("timeout_seconds", 300) or 300),
|
||||
on_timeout=str(args.get("on_timeout", "continue") or "continue").strip(),
|
||||
)
|
||||
|
||||
|
||||
async def _handle_ask_peer_and_wait(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
|
||||
return await _run_bound_handler(_handle_ask_peer_and_wait_bound, args, env)
|
||||
|
||||
|
||||
async def _handle_read_inbox_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
|
||||
if "mark_read" in args:
|
||||
return {"error": "`read_inbox` no longer accepts `mark_read`; reads always archive to seen."}
|
||||
service = binding.service
|
||||
context = binding.context
|
||||
messages = await service.read_inbox(
|
||||
context,
|
||||
agent_id=context.role_id,
|
||||
task=context.task,
|
||||
task_id=context.task_id or None,
|
||||
unread_only=True,
|
||||
limit=int(args.get("limit", 10) or 10),
|
||||
mark_read=True,
|
||||
)
|
||||
return {
|
||||
"count": len(messages),
|
||||
"messages": [_simple_message_payload(message) for message in messages],
|
||||
}
|
||||
|
||||
|
||||
async def _handle_read_inbox(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
|
||||
return await _run_bound_handler(_handle_read_inbox_bound, args, env)
|
||||
|
||||
|
||||
async def _handle_reply_message_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
|
||||
service = binding.service
|
||||
context = binding.context
|
||||
reply = await service.reply_message(
|
||||
context,
|
||||
original_msg_id=str(args.get("message_id", "")).strip(),
|
||||
from_agent=context.role_id,
|
||||
body=str(args.get("body", "")).strip(),
|
||||
subject=str(args.get("subject", "")).strip(),
|
||||
task_id=context.task_id or None,
|
||||
)
|
||||
return {"delivered": True, "message": _simple_message_payload(service.host._serialize_message(reply))}
|
||||
|
||||
|
||||
async def _handle_reply_message(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
|
||||
return await _run_bound_handler(_handle_reply_message_bound, args, env)
|
||||
|
||||
|
||||
async def _handle_broadcast_issue_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
|
||||
service = binding.service
|
||||
context = binding.context
|
||||
task = context.task
|
||||
message = AgentMessage(
|
||||
msg_type="flag_issue",
|
||||
from_agent=context.role_id,
|
||||
to_agents=[str(item).strip() for item in list(args.get("to_agents", []) or []) if str(item).strip()],
|
||||
subject=str(args.get("subject", "")).strip(),
|
||||
body=str(args.get("body", "")).strip(),
|
||||
context_ref=getattr(task, "id", None),
|
||||
task_id=getattr(task, "id", None),
|
||||
urgency=MessageUrgency.HIGH,
|
||||
metadata={
|
||||
"broadcast": True,
|
||||
"async_mailbox": True,
|
||||
"reply_requested": False,
|
||||
},
|
||||
)
|
||||
delivered = await service.send_dm(context, message, task=task)
|
||||
return {"delivered": True, "message": _simple_message_payload(service.host._serialize_message(delivered))}
|
||||
|
||||
|
||||
async def _handle_broadcast_issue(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
|
||||
return await _run_bound_handler(_handle_broadcast_issue_bound, args, env)
|
||||
|
||||
|
||||
async def _handle_list_colleagues_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
|
||||
_ = args
|
||||
return await binding.service.list_colleagues(binding.context)
|
||||
|
||||
|
||||
async def _handle_list_colleagues(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
|
||||
return await _run_bound_handler(_handle_list_colleagues_bound, args, env)
|
||||
|
||||
|
||||
async def _handle_start_meeting_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
|
||||
service = binding.service
|
||||
context = binding.context
|
||||
if context.task is None:
|
||||
return {"error": "`start_meeting` requires an active task context."}
|
||||
return await service.open_meeting_wait(
|
||||
context,
|
||||
task=context.task,
|
||||
topic=str(args.get("topic", "")).strip(),
|
||||
participants=[str(item).strip() for item in list(args.get("participants", []) or []) if str(item).strip()],
|
||||
agenda=[str(item).strip() for item in list(args.get("agenda", []) or []) if str(item).strip()],
|
||||
shared_context=str(args.get("shared_context", "")).strip(),
|
||||
decision_owner=str(args.get("decision_owner", "")).strip() or None,
|
||||
decision_policy=str(args.get("decision_policy", "semantic_consensus_then_owner") or "semantic_consensus_then_owner").strip(),
|
||||
timeout_seconds=int(args.get("timeout_seconds", 900) or 900),
|
||||
risk_level=str(args.get("risk_level", "normal") or "normal").strip(),
|
||||
)
|
||||
|
||||
|
||||
async def _handle_start_meeting(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
|
||||
return await _run_bound_handler(_handle_start_meeting_bound, args, env)
|
||||
|
||||
|
||||
async def _handle_respond_meeting_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
|
||||
service = binding.service
|
||||
context = binding.context
|
||||
meeting = await service.respond_to_meeting(
|
||||
context,
|
||||
room_id=str(args.get("meeting_id", "")).strip(),
|
||||
from_agent=context.role_id,
|
||||
content=str(args.get("content", "")).strip(),
|
||||
finalize=bool(args.get("finalize", False)),
|
||||
task=context.task,
|
||||
)
|
||||
return {
|
||||
"meeting": {
|
||||
"room_id": meeting.room_id,
|
||||
"status": meeting.status.value,
|
||||
"outcome": meeting.outcome,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def _handle_respond_meeting(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
|
||||
return await _run_bound_handler(_handle_respond_meeting_bound, args, env)
|
||||
|
||||
|
||||
async def _handle_read_meeting_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
|
||||
return await binding.service.read_meeting(binding.context, meeting_id=str(args.get("meeting_id", "")).strip())
|
||||
|
||||
|
||||
async def _handle_read_meeting(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
|
||||
return await _run_bound_handler(_handle_read_meeting_bound, args, env)
|
||||
|
||||
|
||||
async def _handle_propose_task_adjustment_bound(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
|
||||
return await binding.service.propose_task_adjustment(
|
||||
binding.context,
|
||||
summary=str(args.get("summary", "")).strip(),
|
||||
changeset=dict(args.get("changeset", {}) or {}),
|
||||
)
|
||||
|
||||
|
||||
async def _handle_propose_task_adjustment(args: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
|
||||
return await _run_bound_handler(_handle_propose_task_adjustment_bound, args, env)
|
||||
|
||||
|
||||
def _native_handler_bound(tool_name: str) -> BoundHandler:
|
||||
async def _handler(args: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
|
||||
native_defs = {tool.name: tool for tool in create_collaboration_tools(binding.manager)}
|
||||
tool = native_defs.get(tool_name)
|
||||
if tool is None:
|
||||
return {"error": f"unknown tool: {tool_name}"}
|
||||
tool_args = dict(args or {})
|
||||
if tool_name == "respond_meeting" and "meeting_id" in tool_args and "room_id" not in tool_args:
|
||||
tool_args["room_id"] = tool_args.pop("meeting_id")
|
||||
result = await tool.func(task=binding.context.task, **tool_args)
|
||||
return result if isinstance(result, dict) else {"result": result}
|
||||
|
||||
return _handler
|
||||
|
||||
|
||||
def _native_handler(tool_name: str) -> Handler:
|
||||
return _env_handler(_native_handler_bound(tool_name))
|
||||
|
||||
|
||||
BOUND_HANDLERS: dict[str, BoundHandler] = {
|
||||
"inbox": _handle_inbox_bound,
|
||||
"send_dm": _handle_send_dm_bound,
|
||||
"ask_peer_and_wait": _handle_ask_peer_and_wait_bound,
|
||||
"request_user_input": _native_handler_bound("request_user_input"),
|
||||
"read_inbox": _handle_read_inbox_bound,
|
||||
"reply_message": _handle_reply_message_bound,
|
||||
"broadcast_issue": _handle_broadcast_issue_bound,
|
||||
"list_colleagues": _handle_list_colleagues_bound,
|
||||
"start_meeting": _handle_start_meeting_bound,
|
||||
"respond_meeting": _handle_respond_meeting_bound,
|
||||
"read_meeting": _handle_read_meeting_bound,
|
||||
"propose_task_adjustment": _handle_propose_task_adjustment_bound,
|
||||
"route_work": _native_handler_bound("route_work"),
|
||||
"close_human_review": _native_handler_bound("close_human_review"),
|
||||
"delegate_work": _native_handler_bound("delegate_work"),
|
||||
"modify_work_item": _native_handler_bound("modify_work_item"),
|
||||
"delete_work_item": _native_handler_bound("delete_work_item"),
|
||||
"manager_board_read": _native_handler_bound("manager_board_read"),
|
||||
}
|
||||
|
||||
HANDLERS: dict[str, Handler] = {
|
||||
name: _env_handler(handler)
|
||||
for name, handler in BOUND_HANDLERS.items()
|
||||
}
|
||||
|
||||
|
||||
def _is_infrastructure_error_text(value: Any) -> bool:
|
||||
text = str(value or "").strip().lower()
|
||||
if not text:
|
||||
return False
|
||||
markers = (
|
||||
"disk i/o error",
|
||||
"database is locked",
|
||||
"readonly database",
|
||||
"unable to open database file",
|
||||
"collaboration broker rpc",
|
||||
"broker rpc",
|
||||
"sqlite",
|
||||
)
|
||||
return any(marker in text for marker in markers)
|
||||
|
||||
|
||||
def infrastructure_error_payload(error: Any, *, tool_name: str = "") -> dict[str, Any]:
|
||||
payload = {
|
||||
"error": str(error or "collaboration infrastructure error"),
|
||||
"error_type": "infrastructure",
|
||||
"retryable": True,
|
||||
}
|
||||
if tool_name:
|
||||
payload["tool_name"] = tool_name
|
||||
return payload
|
||||
|
||||
|
||||
async def _mailbox_notice_bound(binding: CollaborationRuntimeBinding) -> dict[str, Any] | None:
|
||||
service = binding.service
|
||||
context = binding.context
|
||||
if context.task is None or not context.role_id:
|
||||
return None
|
||||
status = await service.inbox(
|
||||
context,
|
||||
agent_id=context.role_id,
|
||||
task=context.task,
|
||||
action="status",
|
||||
limit=3,
|
||||
)
|
||||
if not bool(status.get("has_actionable_unread", False)):
|
||||
return None
|
||||
return {
|
||||
"has_actionable_unread": True,
|
||||
"unread_count": int(status.get("unread_count", 0) or 0),
|
||||
"actionable_count": int(status.get("actionable_count", 0) or 0),
|
||||
"blocking_count": int(status.get("blocking_count", 0) or 0),
|
||||
"latest_unread_summary": list(status.get("latest_unread_summary", []) or [])[:3],
|
||||
"hint": "Call `opc-collab inbox --args-stdin` with JSON {\"action\":\"peek\"} to inspect, then reply or ack handled messages.",
|
||||
}
|
||||
|
||||
|
||||
async def _mailbox_notice(env: Mapping[str, str] | None = None) -> dict[str, Any] | None:
|
||||
binding = await build_collaboration_runtime_binding_from_env(env)
|
||||
try:
|
||||
return await _mailbox_notice_bound(binding)
|
||||
finally:
|
||||
if binding.owns_store and binding.store is not None:
|
||||
await binding.store.close()
|
||||
|
||||
|
||||
async def _attach_mailbox_notice_bound(payload: dict[str, Any], binding: CollaborationRuntimeBinding) -> dict[str, Any]:
|
||||
try:
|
||||
notice = await _mailbox_notice_bound(binding)
|
||||
except Exception:
|
||||
notice = None
|
||||
if notice:
|
||||
payload.setdefault("mailbox_notice", notice)
|
||||
return payload
|
||||
|
||||
|
||||
async def _attach_mailbox_notice(payload: dict[str, Any], env: Mapping[str, str] | None = None) -> dict[str, Any]:
|
||||
binding = await build_collaboration_runtime_binding_from_env(env)
|
||||
try:
|
||||
return await _attach_mailbox_notice_bound(payload, binding)
|
||||
finally:
|
||||
if binding.owns_store and binding.store is not None:
|
||||
await binding.store.close()
|
||||
|
||||
|
||||
async def dispatch_collaboration_tool(
|
||||
tool_name: str,
|
||||
args: dict[str, Any],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
"""Call one collaboration tool and return ``(result, is_error)``."""
|
||||
binding = await build_collaboration_runtime_binding_from_env(env)
|
||||
try:
|
||||
return await dispatch_collaboration_tool_bound(tool_name, args, binding)
|
||||
finally:
|
||||
if binding.owns_store and binding.store is not None:
|
||||
await binding.store.close()
|
||||
|
||||
|
||||
async def dispatch_collaboration_tool_bound(
|
||||
tool_name: str,
|
||||
args: dict[str, Any],
|
||||
binding: CollaborationRuntimeBinding,
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
"""Call one collaboration tool using an already-bound runtime/store."""
|
||||
name = str(tool_name or "").strip()
|
||||
handler = BOUND_HANDLERS.get(name)
|
||||
if handler is None:
|
||||
return await _attach_mailbox_notice_bound({"error": f"unknown tool: {name}"}, binding), True
|
||||
|
||||
allowed = binding.allowed_tools
|
||||
if allowed is None:
|
||||
allowed = allowed_tool_names(
|
||||
task=binding.context.task,
|
||||
context=binding.context,
|
||||
manager=binding.manager,
|
||||
env=binding.env,
|
||||
)
|
||||
|
||||
if name not in allowed:
|
||||
return (
|
||||
await _attach_mailbox_notice_bound(
|
||||
{
|
||||
"error": (
|
||||
f"tool `{name}` is not available for this run. "
|
||||
f"Allowed tools: {', '.join(sorted(allowed)) or '(none)'}."
|
||||
)
|
||||
},
|
||||
binding,
|
||||
),
|
||||
True,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await handler(dict(args or {}), binding)
|
||||
except Exception as exc:
|
||||
payload = (
|
||||
infrastructure_error_payload(exc, tool_name=name)
|
||||
if _is_infrastructure_error_text(exc)
|
||||
else {"error": str(exc)}
|
||||
)
|
||||
return await _attach_mailbox_notice_bound(payload, binding), True
|
||||
normalized = result if isinstance(result, dict) else {"result": result}
|
||||
if "error" in normalized and _is_infrastructure_error_text(normalized.get("error")):
|
||||
normalized = {**normalized, **infrastructure_error_payload(normalized.get("error"), tool_name=name)}
|
||||
normalized = await _attach_mailbox_notice_bound(normalized, binding)
|
||||
return normalized, bool("error" in normalized)
|
||||
|
||||
|
||||
# Backward-compatible names for tests and small internal call sites that used
|
||||
# the old module-local helper spellings.
|
||||
_build_runtime = build_collaboration_runtime
|
||||
_allowed_tool_names = allowed_tool_names
|
||||
@@ -0,0 +1,545 @@
|
||||
"""Local RPC transport for ``opc-collab`` calls.
|
||||
|
||||
The external agent still invokes the normal ``opc-collab`` CLI. When OpenOPC
|
||||
spawns that agent, the broker exposes a short-lived local endpoint and injects
|
||||
its address/token into the environment. The CLI then sends the collaboration
|
||||
tool call to the already-running broker, so database writes stay in the host
|
||||
runtime instead of inside the agent sandbox.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import tempfile
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
|
||||
OPC_COLLAB_RPC_PATH = "OPC_COLLAB_RPC_PATH"
|
||||
OPC_COLLAB_RPC_TOKEN = "OPC_COLLAB_RPC_TOKEN"
|
||||
OPC_COLLAB_RPC_TRANSPORT = "OPC_COLLAB_RPC_TRANSPORT"
|
||||
OPC_COLLAB_RPC_HOST = "OPC_COLLAB_RPC_HOST"
|
||||
OPC_COLLAB_RPC_PORT = "OPC_COLLAB_RPC_PORT"
|
||||
_RPC_TIMEOUT_SECONDS = 30.0
|
||||
_RPC_MAX_BYTES = 16 * 1024 * 1024
|
||||
|
||||
DispatchCallable = Callable[[str, dict[str, Any]], Awaitable[tuple[dict[str, Any], bool]]]
|
||||
RpcTransport = Literal["auto", "fifo", "tcp"]
|
||||
|
||||
|
||||
def _infrastructure_error(message: str, *, tool_name: str = "") -> dict[str, Any]:
|
||||
payload = {
|
||||
"error": str(message or "collaboration broker RPC failed"),
|
||||
"error_type": "infrastructure",
|
||||
"retryable": True,
|
||||
}
|
||||
if tool_name:
|
||||
payload["tool_name"] = tool_name
|
||||
return payload
|
||||
|
||||
|
||||
def fifo_rpc_supported() -> bool:
|
||||
"""Return whether this runtime can create POSIX FIFOs."""
|
||||
return os.name != "nt" and callable(getattr(os, "mkfifo", None))
|
||||
|
||||
|
||||
def default_collaboration_rpc_transport() -> Literal["fifo", "tcp"]:
|
||||
return "fifo" if fifo_rpc_supported() else "tcp"
|
||||
|
||||
|
||||
def resolve_collaboration_rpc_transport(
|
||||
transport: str | None = "auto",
|
||||
) -> Literal["fifo", "tcp"]:
|
||||
normalized = str(transport or "auto").strip().lower()
|
||||
if normalized in {"", "auto"}:
|
||||
return default_collaboration_rpc_transport()
|
||||
if normalized == "fifo":
|
||||
if not fifo_rpc_supported():
|
||||
raise RuntimeError("FIFO collaboration RPC is unavailable on this platform")
|
||||
return "fifo"
|
||||
if normalized == "tcp":
|
||||
return "tcp"
|
||||
raise ValueError(f"Unsupported collaboration RPC transport: {transport}")
|
||||
|
||||
|
||||
def rpc_env_available(env: Mapping[str, str] | None = None) -> bool:
|
||||
source = env if env is not None else os.environ
|
||||
token = str(source.get(OPC_COLLAB_RPC_TOKEN, "")).strip()
|
||||
if not token:
|
||||
return False
|
||||
transport = str(source.get(OPC_COLLAB_RPC_TRANSPORT, "")).strip().lower()
|
||||
if not transport:
|
||||
# Legacy FIFO environment from older brokers.
|
||||
return bool(str(source.get(OPC_COLLAB_RPC_PATH, "")).strip())
|
||||
if transport == "fifo":
|
||||
return bool(str(source.get(OPC_COLLAB_RPC_PATH, "")).strip())
|
||||
if transport == "tcp":
|
||||
host = str(source.get(OPC_COLLAB_RPC_HOST, "")).strip()
|
||||
raw_port = str(source.get(OPC_COLLAB_RPC_PORT, "")).strip()
|
||||
try:
|
||||
port = int(raw_port)
|
||||
except ValueError:
|
||||
return False
|
||||
return bool(host) and 0 < port <= 65535
|
||||
return False
|
||||
|
||||
|
||||
def rpc_env_configured(env: Mapping[str, str] | None = None) -> bool:
|
||||
source = env if env is not None else os.environ
|
||||
return any(
|
||||
str(source.get(key, "")).strip()
|
||||
for key in (
|
||||
OPC_COLLAB_RPC_TRANSPORT,
|
||||
OPC_COLLAB_RPC_PATH,
|
||||
OPC_COLLAB_RPC_HOST,
|
||||
OPC_COLLAB_RPC_PORT,
|
||||
OPC_COLLAB_RPC_TOKEN,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _json_line(payload: dict[str, Any]) -> bytes:
|
||||
return json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8") + b"\n"
|
||||
|
||||
|
||||
def _decode_rpc_response(raw: bytes, *, tool_name: str) -> tuple[dict[str, Any], bool]:
|
||||
try:
|
||||
response = json.loads(raw.decode("utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
return _infrastructure_error(f"collaboration broker RPC returned invalid JSON: {exc}", tool_name=tool_name), True
|
||||
if not isinstance(response, dict):
|
||||
return _infrastructure_error("collaboration broker RPC returned a non-object response", tool_name=tool_name), True
|
||||
|
||||
result = response.get("result")
|
||||
normalized = result if isinstance(result, dict) else {"result": result}
|
||||
is_error = bool(response.get("is_error")) or "error" in normalized
|
||||
return normalized, is_error
|
||||
|
||||
|
||||
def _request_payload(
|
||||
tool_name: str,
|
||||
args: dict[str, Any],
|
||||
*,
|
||||
token: str,
|
||||
response_path: str = "",
|
||||
) -> dict[str, Any]:
|
||||
request = {
|
||||
"token": token,
|
||||
"tool_name": str(tool_name or "").strip(),
|
||||
"args": dict(args or {}),
|
||||
}
|
||||
if response_path:
|
||||
request["response_path"] = response_path
|
||||
return request
|
||||
|
||||
|
||||
def _write_fifo_nonblocking(path: Path, payload: dict[str, Any]) -> None:
|
||||
data = _json_line(payload)
|
||||
if len(data) > _RPC_MAX_BYTES:
|
||||
raise ValueError("collaboration RPC request exceeds max payload size")
|
||||
try:
|
||||
fd = os.open(path, os.O_WRONLY | os.O_NONBLOCK)
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.ENXIO:
|
||||
raise RuntimeError("collaboration broker RPC is not accepting requests") from exc
|
||||
raise
|
||||
try:
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
try:
|
||||
written = os.write(fd, view)
|
||||
view = view[written:]
|
||||
except BlockingIOError:
|
||||
_readable, writable, _errors = select.select([], [fd], [], _RPC_TIMEOUT_SECONDS)
|
||||
if not writable:
|
||||
raise TimeoutError("collaboration broker RPC write timed out")
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
async def _read_fifo_response(fd: int, *, timeout_seconds: float) -> bytes:
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout_seconds
|
||||
chunks = bytearray()
|
||||
while loop.time() < deadline:
|
||||
try:
|
||||
chunk = os.read(fd, 65536)
|
||||
except BlockingIOError:
|
||||
await asyncio.sleep(0.02)
|
||||
continue
|
||||
if chunk:
|
||||
chunks.extend(chunk)
|
||||
if b"\n" in chunk:
|
||||
line, _sep, _rest = bytes(chunks).partition(b"\n")
|
||||
return line + b"\n"
|
||||
if len(chunks) > _RPC_MAX_BYTES:
|
||||
raise RuntimeError("collaboration broker RPC response exceeds max payload size")
|
||||
else:
|
||||
await asyncio.sleep(0.02)
|
||||
raise TimeoutError("collaboration broker RPC response timed out")
|
||||
|
||||
|
||||
async def call_collaboration_rpc(
|
||||
tool_name: str,
|
||||
args: dict[str, Any],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
"""Call the broker-owned collaboration RPC endpoint from ``opc-collab``."""
|
||||
source = env if env is not None else os.environ
|
||||
transport = str(source.get(OPC_COLLAB_RPC_TRANSPORT, "")).strip().lower() or "fifo"
|
||||
if transport == "fifo":
|
||||
return await _call_fifo_collaboration_rpc(tool_name, args, env=source)
|
||||
if transport == "tcp":
|
||||
return await _call_tcp_collaboration_rpc(tool_name, args, env=source)
|
||||
return _infrastructure_error(
|
||||
f"collaboration broker RPC transport is unsupported: {transport}",
|
||||
tool_name=tool_name,
|
||||
), True
|
||||
|
||||
|
||||
async def _call_fifo_collaboration_rpc(
|
||||
tool_name: str,
|
||||
args: dict[str, Any],
|
||||
*,
|
||||
env: Mapping[str, str],
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
token = str(env.get(OPC_COLLAB_RPC_TOKEN, "")).strip()
|
||||
raw_request_path = str(env.get(OPC_COLLAB_RPC_PATH, "")).strip()
|
||||
if not raw_request_path or not token:
|
||||
return _infrastructure_error("collaboration broker RPC is not configured", tool_name=tool_name), True
|
||||
if not fifo_rpc_supported():
|
||||
return _infrastructure_error(
|
||||
"FIFO collaboration broker RPC is unavailable on this platform",
|
||||
tool_name=tool_name,
|
||||
), True
|
||||
request_path = Path(raw_request_path)
|
||||
|
||||
response_dir = request_path.parent / "responses"
|
||||
response_path = response_dir / f"{uuid.uuid4().hex}.fifo"
|
||||
response_fd: int | None = None
|
||||
try:
|
||||
response_dir.mkdir(parents=True, exist_ok=True)
|
||||
os.mkfifo(response_path, 0o600)
|
||||
response_fd = os.open(response_path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
request = _request_payload(
|
||||
tool_name,
|
||||
args,
|
||||
token=token,
|
||||
response_path=str(response_path),
|
||||
)
|
||||
_write_fifo_nonblocking(request_path, request)
|
||||
raw = await _read_fifo_response(response_fd, timeout_seconds=_RPC_TIMEOUT_SECONDS)
|
||||
except Exception as exc:
|
||||
return _infrastructure_error(f"collaboration broker RPC failed: {exc}", tool_name=tool_name), True
|
||||
finally:
|
||||
if response_fd is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(response_fd)
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
response_path.unlink()
|
||||
|
||||
return _decode_rpc_response(raw, tool_name=tool_name)
|
||||
|
||||
|
||||
async def _call_tcp_collaboration_rpc(
|
||||
tool_name: str,
|
||||
args: dict[str, Any],
|
||||
*,
|
||||
env: Mapping[str, str],
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
host = str(env.get(OPC_COLLAB_RPC_HOST, "")).strip()
|
||||
raw_port = str(env.get(OPC_COLLAB_RPC_PORT, "")).strip()
|
||||
token = str(env.get(OPC_COLLAB_RPC_TOKEN, "")).strip()
|
||||
if not host or not raw_port or not token:
|
||||
return _infrastructure_error("collaboration broker RPC is not configured", tool_name=tool_name), True
|
||||
try:
|
||||
port = int(raw_port)
|
||||
except ValueError:
|
||||
return _infrastructure_error(
|
||||
f"collaboration broker RPC port is invalid: {raw_port}",
|
||||
tool_name=tool_name,
|
||||
), True
|
||||
if not 0 < port <= 65535:
|
||||
return _infrastructure_error(
|
||||
f"collaboration broker RPC port is out of range: {port}",
|
||||
tool_name=tool_name,
|
||||
), True
|
||||
|
||||
request = _request_payload(tool_name, args, token=token)
|
||||
data = _json_line(request)
|
||||
if len(data) > _RPC_MAX_BYTES:
|
||||
return _infrastructure_error(
|
||||
"collaboration RPC request exceeds max payload size",
|
||||
tool_name=tool_name,
|
||||
), True
|
||||
|
||||
writer: asyncio.StreamWriter | None = None
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host=host, port=port, limit=_RPC_MAX_BYTES + 1024),
|
||||
timeout=_RPC_TIMEOUT_SECONDS,
|
||||
)
|
||||
writer.write(data)
|
||||
await asyncio.wait_for(writer.drain(), timeout=_RPC_TIMEOUT_SECONDS)
|
||||
raw = await _read_stream_line(reader, timeout_seconds=_RPC_TIMEOUT_SECONDS)
|
||||
except Exception as exc:
|
||||
return _infrastructure_error(f"collaboration broker RPC failed: {exc}", tool_name=tool_name), True
|
||||
finally:
|
||||
if writer is not None:
|
||||
writer.close()
|
||||
with contextlib.suppress(Exception):
|
||||
await writer.wait_closed()
|
||||
|
||||
return _decode_rpc_response(raw, tool_name=tool_name)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CollaborationRpcServer:
|
||||
transport: Literal["fifo", "tcp"]
|
||||
token: str
|
||||
request_path: Path | None = None
|
||||
rpc_dir: Path | None = None
|
||||
request_fd: int | None = None
|
||||
task: asyncio.Task[None] | None = None
|
||||
tcp_server: asyncio.AbstractServer | None = None
|
||||
host: str = ""
|
||||
port: int = 0
|
||||
|
||||
@property
|
||||
def client_env(self) -> dict[str, str]:
|
||||
if self.transport == "tcp":
|
||||
return {
|
||||
OPC_COLLAB_RPC_TRANSPORT: "tcp",
|
||||
OPC_COLLAB_RPC_HOST: self.host,
|
||||
OPC_COLLAB_RPC_PORT: str(self.port),
|
||||
OPC_COLLAB_RPC_TOKEN: self.token,
|
||||
}
|
||||
return {
|
||||
OPC_COLLAB_RPC_PATH: str(self.request_path or ""),
|
||||
OPC_COLLAB_RPC_TOKEN: self.token,
|
||||
OPC_COLLAB_RPC_TRANSPORT: "fifo",
|
||||
}
|
||||
|
||||
async def close(self) -> None:
|
||||
if self.tcp_server is not None:
|
||||
self.tcp_server.close()
|
||||
with contextlib.suppress(Exception):
|
||||
await self.tcp_server.wait_closed()
|
||||
if self.task is not None:
|
||||
self.task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self.task
|
||||
if self.request_fd is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(self.request_fd)
|
||||
if self.rpc_dir is not None:
|
||||
shutil.rmtree(self.rpc_dir, ignore_errors=True)
|
||||
|
||||
|
||||
async def start_collaboration_rpc_server(
|
||||
dispatch: DispatchCallable,
|
||||
*,
|
||||
transport_parent: str | os.PathLike[str] | None = None,
|
||||
transport: RpcTransport = "auto",
|
||||
) -> CollaborationRpcServer | None:
|
||||
"""Start a broker-local collaboration RPC server."""
|
||||
resolved_transport = resolve_collaboration_rpc_transport(transport)
|
||||
if resolved_transport == "tcp":
|
||||
return await _start_tcp_collaboration_rpc_server(dispatch)
|
||||
return await _start_fifo_collaboration_rpc_server(dispatch, transport_parent=transport_parent)
|
||||
|
||||
|
||||
async def _start_fifo_collaboration_rpc_server(
|
||||
dispatch: DispatchCallable,
|
||||
*,
|
||||
transport_parent: str | os.PathLike[str] | None = None,
|
||||
) -> CollaborationRpcServer:
|
||||
parent = Path(transport_parent) if transport_parent else Path(tempfile.gettempdir())
|
||||
rpc_dir = Path(tempfile.mkdtemp(prefix="openopc-collab-rpc-", dir=str(parent)))
|
||||
request_path = rpc_dir / "requests.fifo"
|
||||
token = secrets.token_urlsafe(32)
|
||||
os.mkfifo(request_path, 0o600)
|
||||
request_fd = os.open(request_path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
|
||||
async def _serve() -> None:
|
||||
buffer = bytearray()
|
||||
while True:
|
||||
try:
|
||||
chunk = os.read(request_fd, 65536)
|
||||
except BlockingIOError:
|
||||
await asyncio.sleep(0.02)
|
||||
continue
|
||||
if not chunk:
|
||||
await asyncio.sleep(0.02)
|
||||
continue
|
||||
buffer.extend(chunk)
|
||||
if len(buffer) > _RPC_MAX_BYTES:
|
||||
buffer.clear()
|
||||
continue
|
||||
while True:
|
||||
newline_index = buffer.find(b"\n")
|
||||
if newline_index < 0:
|
||||
break
|
||||
raw = bytes(buffer[:newline_index])
|
||||
del buffer[: newline_index + 1]
|
||||
await _handle_request_line(raw)
|
||||
|
||||
async def _handle_request_line(raw: bytes) -> None:
|
||||
if not raw:
|
||||
return
|
||||
try:
|
||||
request = json.loads(raw.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
if not isinstance(request, dict):
|
||||
return
|
||||
response = await _handle_rpc_request(request, dispatch=dispatch, token=token)
|
||||
await _respond(request, response)
|
||||
|
||||
task = asyncio.create_task(_serve())
|
||||
return CollaborationRpcServer(
|
||||
transport="fifo",
|
||||
request_path=request_path,
|
||||
rpc_dir=rpc_dir,
|
||||
token=token,
|
||||
request_fd=request_fd,
|
||||
task=task,
|
||||
)
|
||||
|
||||
|
||||
async def _start_tcp_collaboration_rpc_server(dispatch: DispatchCallable) -> CollaborationRpcServer:
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
async def _handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
try:
|
||||
raw = await _read_stream_line(reader, timeout_seconds=_RPC_TIMEOUT_SECONDS)
|
||||
try:
|
||||
request = json.loads(raw.decode("utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
response = {
|
||||
"result": _infrastructure_error(
|
||||
f"collaboration broker RPC received invalid JSON: {exc}",
|
||||
),
|
||||
"is_error": True,
|
||||
}
|
||||
else:
|
||||
if isinstance(request, dict):
|
||||
response = await _handle_rpc_request(request, dispatch=dispatch, token=token)
|
||||
else:
|
||||
response = {
|
||||
"result": _infrastructure_error("collaboration broker RPC received a non-object request"),
|
||||
"is_error": True,
|
||||
}
|
||||
data = _json_line(response)
|
||||
if len(data) > _RPC_MAX_BYTES:
|
||||
data = _json_line(
|
||||
{
|
||||
"result": _infrastructure_error("collaboration broker RPC response exceeds max payload size"),
|
||||
"is_error": True,
|
||||
}
|
||||
)
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
writer.write(
|
||||
_json_line(
|
||||
{
|
||||
"result": _infrastructure_error("collaboration broker RPC connection failed"),
|
||||
"is_error": True,
|
||||
}
|
||||
)
|
||||
)
|
||||
await writer.drain()
|
||||
finally:
|
||||
writer.close()
|
||||
with contextlib.suppress(Exception):
|
||||
await writer.wait_closed()
|
||||
|
||||
server = await asyncio.start_server(
|
||||
_handle_client,
|
||||
host="127.0.0.1",
|
||||
port=0,
|
||||
family=socket.AF_INET,
|
||||
limit=_RPC_MAX_BYTES + 1024,
|
||||
)
|
||||
sockets = server.sockets or []
|
||||
if not sockets:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
raise RuntimeError("collaboration RPC TCP server did not expose a listening socket")
|
||||
host, port = sockets[0].getsockname()[:2]
|
||||
return CollaborationRpcServer(
|
||||
transport="tcp",
|
||||
token=token,
|
||||
tcp_server=server,
|
||||
host=str(host),
|
||||
port=int(port),
|
||||
)
|
||||
|
||||
|
||||
async def _read_stream_line(
|
||||
reader: asyncio.StreamReader,
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
) -> bytes:
|
||||
buffer = bytearray()
|
||||
while True:
|
||||
chunk = await asyncio.wait_for(reader.read(65536), timeout=timeout_seconds)
|
||||
if not chunk:
|
||||
if buffer:
|
||||
return bytes(buffer)
|
||||
raise RuntimeError("collaboration broker RPC connection closed before a response")
|
||||
buffer.extend(chunk)
|
||||
if len(buffer) > _RPC_MAX_BYTES:
|
||||
raise RuntimeError("collaboration broker RPC payload exceeds max size")
|
||||
newline_index = buffer.find(b"\n")
|
||||
if newline_index >= 0:
|
||||
return bytes(buffer[: newline_index + 1])
|
||||
|
||||
|
||||
async def _handle_rpc_request(
|
||||
request: dict[str, Any],
|
||||
*,
|
||||
dispatch: DispatchCallable,
|
||||
token: str,
|
||||
) -> dict[str, Any]:
|
||||
tool_name = str(request.get("tool_name", "") or "").strip()
|
||||
if str(request.get("token", "")) != token:
|
||||
return {
|
||||
"result": _infrastructure_error("collaboration RPC token rejected", tool_name=tool_name),
|
||||
"is_error": True,
|
||||
}
|
||||
raw_args = request.get("args")
|
||||
tool_args = raw_args if isinstance(raw_args, dict) else {}
|
||||
try:
|
||||
result, is_error = await dispatch(tool_name, tool_args)
|
||||
return {"result": result, "is_error": bool(is_error)}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"result": _infrastructure_error(
|
||||
f"collaboration broker RPC failed: {exc}",
|
||||
tool_name=tool_name,
|
||||
),
|
||||
"is_error": True,
|
||||
}
|
||||
|
||||
|
||||
async def _respond(request: dict[str, Any], response: dict[str, Any]) -> None:
|
||||
response_path = Path(str(request.get("response_path", "") or "").strip())
|
||||
if not response_path:
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
_write_fifo_nonblocking(response_path, response)
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Execution context helpers for isolated runtime tool execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from opc.core.config import OPCConfig
|
||||
|
||||
|
||||
def platform_key() -> str:
|
||||
if sys.platform.startswith("win"):
|
||||
return "windows"
|
||||
if sys.platform == "darwin":
|
||||
return "macos"
|
||||
return "linux"
|
||||
|
||||
|
||||
def venv_python_path(venv_path: str | Path) -> Path:
|
||||
base = Path(venv_path)
|
||||
if platform_key() == "windows":
|
||||
return base / "Scripts" / "python.exe"
|
||||
return base / "bin" / "python"
|
||||
|
||||
|
||||
def build_task_execution_context(
|
||||
*,
|
||||
workspace_root: str | Path | None,
|
||||
output_root: str | Path | None = None,
|
||||
comms_root: str | Path | None = None,
|
||||
config: OPCConfig | None = None,
|
||||
venv_path: str | Path | None = None,
|
||||
python_executable: str | Path | None = None,
|
||||
venv_provider: str = "",
|
||||
preparation_error: str = "",
|
||||
) -> dict[str, Any]:
|
||||
workspace = Path(workspace_root or os.getcwd()).resolve()
|
||||
output = Path(output_root).resolve() if output_root else workspace
|
||||
resolved_comms_root = Path(comms_root).resolve() if comms_root else (workspace / ".opc-comms")
|
||||
resolved_venv = Path(venv_path).resolve() if venv_path else None
|
||||
resolved_python = Path(python_executable).resolve() if python_executable else None
|
||||
return {
|
||||
"workspace_root": str(workspace),
|
||||
"output_root": str(output),
|
||||
"comms_root": str(resolved_comms_root),
|
||||
"venv_path": str(resolved_venv) if resolved_venv else "",
|
||||
"python_executable": str(resolved_python) if resolved_python else "",
|
||||
"venv_provider": str(venv_provider or "").strip(),
|
||||
"preparation_error": str(preparation_error or "").strip(),
|
||||
"sandbox": resolve_sandbox_config(config),
|
||||
}
|
||||
|
||||
|
||||
def ensure_task_execution_context(task: Any, config: OPCConfig | None = None) -> dict[str, Any]:
|
||||
metadata = getattr(task, "metadata", {}) or {}
|
||||
existing = dict(metadata.get("_execution_context", {}) or {})
|
||||
if existing:
|
||||
return existing
|
||||
workspace_root = (
|
||||
str(metadata.get("workspace_root", "") or "").strip()
|
||||
or str(metadata.get("comms_workspace_root", "") or "").strip()
|
||||
or str(metadata.get("target_output_dir", "") or "").strip()
|
||||
or os.getcwd()
|
||||
)
|
||||
output_root = (
|
||||
str(metadata.get("output_root", "") or "").strip()
|
||||
or str(metadata.get("target_output_dir", "") or "").strip()
|
||||
or workspace_root
|
||||
)
|
||||
comms_root = (
|
||||
str(metadata.get("comms_root", "") or "").strip()
|
||||
or (str(Path(workspace_root).resolve() / ".opc-comms") if workspace_root else "")
|
||||
)
|
||||
context = build_task_execution_context(
|
||||
workspace_root=workspace_root,
|
||||
output_root=output_root,
|
||||
comms_root=comms_root,
|
||||
config=config,
|
||||
)
|
||||
metadata["_execution_context"] = context
|
||||
setattr(task, "metadata", metadata)
|
||||
return context
|
||||
|
||||
|
||||
def resolve_task_execution_context(task: Any = None) -> dict[str, Any]:
|
||||
if task is None:
|
||||
return {}
|
||||
metadata = getattr(task, "metadata", {}) or {}
|
||||
context = dict(metadata.get("_execution_context", {}) or {})
|
||||
if not context:
|
||||
workspace_root = (
|
||||
str(metadata.get("workspace_root", "") or "").strip()
|
||||
or str(metadata.get("comms_workspace_root", "") or "").strip()
|
||||
or str(metadata.get("target_output_dir", "") or "").strip()
|
||||
)
|
||||
if workspace_root:
|
||||
output_root = (
|
||||
str(metadata.get("output_root", "") or "").strip()
|
||||
or str(metadata.get("target_output_dir", "") or "").strip()
|
||||
or workspace_root
|
||||
)
|
||||
context = {
|
||||
"workspace_root": str(Path(workspace_root).resolve()),
|
||||
"output_root": str(Path(output_root).resolve()),
|
||||
"comms_root": str(Path(workspace_root).resolve() / ".opc-comms"),
|
||||
}
|
||||
|
||||
inherited = metadata.get("inherited_environment")
|
||||
if isinstance(inherited, dict):
|
||||
env_vars = inherited.get("env_vars")
|
||||
if isinstance(env_vars, dict) and env_vars:
|
||||
existing = dict(context.get("inherited_env_vars", {}) or {})
|
||||
existing.update(env_vars)
|
||||
context["inherited_env_vars"] = existing
|
||||
manifest = metadata.get("environment_manifest")
|
||||
if isinstance(manifest, dict):
|
||||
env_vars = manifest.get("env_vars")
|
||||
if isinstance(env_vars, dict) and env_vars:
|
||||
existing = dict(context.get("inherited_env_vars", {}) or {})
|
||||
existing.update(env_vars)
|
||||
context["inherited_env_vars"] = existing
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def resolve_sandbox_config(config: OPCConfig | None = None) -> dict[str, Any]:
|
||||
platform = platform_key()
|
||||
if config is None:
|
||||
return {
|
||||
"platform": platform,
|
||||
"enabled": False,
|
||||
"mode": "off",
|
||||
"wrapper": "none",
|
||||
"fail_if_unavailable": False,
|
||||
"allow_direct_fallback": True,
|
||||
"allow_network": True,
|
||||
}
|
||||
sandbox_cfg = config.system.native_runtime.execution_environment.sandbox
|
||||
platform_cfg = getattr(sandbox_cfg, platform)
|
||||
mode = platform_cfg.mode if platform_cfg.mode != "inherit" else sandbox_cfg.default_mode
|
||||
wrapper = platform_cfg.wrapper
|
||||
if not sandbox_cfg.enabled:
|
||||
mode = "off"
|
||||
wrapper = "none"
|
||||
return {
|
||||
"platform": platform,
|
||||
"enabled": bool(sandbox_cfg.enabled),
|
||||
"mode": mode,
|
||||
"wrapper": wrapper,
|
||||
"fail_if_unavailable": bool(sandbox_cfg.fail_if_unavailable),
|
||||
"allow_direct_fallback": bool(sandbox_cfg.allow_direct_fallback),
|
||||
"allow_network": bool(sandbox_cfg.allow_network),
|
||||
}
|
||||
|
||||
|
||||
def build_subprocess_env(context: dict[str, Any] | None = None) -> dict[str, str]:
|
||||
env = dict(os.environ)
|
||||
context = dict(context or {})
|
||||
|
||||
inherited_env = dict(context.get("inherited_env_vars", {}) or {})
|
||||
if inherited_env:
|
||||
env.update({str(k): str(v) for k, v in inherited_env.items()})
|
||||
|
||||
venv_path = str(context.get("venv_path", "") or "").strip()
|
||||
python_path = str(context.get("python_executable", "") or "").strip()
|
||||
if not venv_path or not python_path:
|
||||
return env
|
||||
if not Path(python_path).exists():
|
||||
return env
|
||||
bin_dir = str(Path(python_path).resolve().parent)
|
||||
env["VIRTUAL_ENV"] = venv_path
|
||||
env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "")
|
||||
env.setdefault("UV_PROJECT_ENVIRONMENT", venv_path)
|
||||
return env
|
||||
|
||||
|
||||
def resolve_python_executable(context: dict[str, Any] | None = None) -> str:
|
||||
context = dict(context or {})
|
||||
configured = str(context.get("python_executable", "") or "").strip()
|
||||
if configured and Path(configured).exists():
|
||||
return configured
|
||||
return shutil.which("python3") or shutil.which("python") or sys.executable
|
||||
|
||||
|
||||
def wrap_command_for_context(
|
||||
args: list[str],
|
||||
*,
|
||||
cwd: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> tuple[list[str], dict[str, Any]]:
|
||||
context = dict(context or {})
|
||||
sandbox = dict(context.get("sandbox", {}) or {})
|
||||
mode = str(sandbox.get("mode", "off") or "off").strip().lower() or "off"
|
||||
requested_wrapper = str(sandbox.get("wrapper", "auto") or "auto").strip().lower() or "auto"
|
||||
platform = str(sandbox.get("platform", "") or platform_key()).strip() or platform_key()
|
||||
meta = {
|
||||
"platform": platform,
|
||||
"requested_mode": mode,
|
||||
"effective_mode": mode,
|
||||
"requested_wrapper": requested_wrapper,
|
||||
"effective_wrapper": "none",
|
||||
"available": True,
|
||||
"fallback_used": False,
|
||||
}
|
||||
if mode in {"", "off", "elevated"}:
|
||||
return args, meta
|
||||
wrapper = requested_wrapper
|
||||
if wrapper in {"", "auto"}:
|
||||
if platform == "linux" and shutil.which("bwrap"):
|
||||
wrapper = "bwrap"
|
||||
elif platform == "macos" and shutil.which("sandbox-exec"):
|
||||
wrapper = "sandbox-exec"
|
||||
else:
|
||||
wrapper = "none"
|
||||
meta["effective_wrapper"] = wrapper
|
||||
if wrapper == "bwrap":
|
||||
return _wrap_with_bwrap(args, cwd=cwd, context=context, meta=meta), meta
|
||||
if wrapper == "sandbox-exec":
|
||||
return _wrap_with_sandbox_exec(args, cwd=cwd, context=context, meta=meta), meta
|
||||
return _handle_unavailable_sandbox(args, sandbox=sandbox, meta=meta)
|
||||
|
||||
|
||||
def _handle_unavailable_sandbox(
|
||||
args: list[str],
|
||||
*,
|
||||
sandbox: dict[str, Any],
|
||||
meta: dict[str, Any],
|
||||
) -> tuple[list[str], dict[str, Any]]:
|
||||
meta["available"] = False
|
||||
allow_direct = bool(sandbox.get("allow_direct_fallback", True))
|
||||
if bool(sandbox.get("fail_if_unavailable", False)) and not allow_direct:
|
||||
raise RuntimeError(
|
||||
f"Sandbox mode `{meta['requested_mode']}` is unavailable on {meta['platform']} and direct fallback is disabled."
|
||||
)
|
||||
meta["fallback_used"] = True
|
||||
meta["effective_mode"] = "off"
|
||||
meta["effective_wrapper"] = "none"
|
||||
return args, meta
|
||||
|
||||
|
||||
def _wrap_with_bwrap(
|
||||
args: list[str],
|
||||
*,
|
||||
cwd: str,
|
||||
context: dict[str, Any],
|
||||
meta: dict[str, Any],
|
||||
) -> list[str]:
|
||||
workspace = str(Path(context.get("workspace_root") or cwd).resolve())
|
||||
wrapped = [
|
||||
"bwrap",
|
||||
"--die-with-parent",
|
||||
"--new-session",
|
||||
"--ro-bind",
|
||||
"/",
|
||||
"/",
|
||||
"--bind",
|
||||
workspace,
|
||||
workspace,
|
||||
"--proc",
|
||||
"/proc",
|
||||
"--dev",
|
||||
"/dev",
|
||||
"--tmpfs",
|
||||
"/tmp",
|
||||
"--chdir",
|
||||
cwd,
|
||||
]
|
||||
sandbox = dict(context.get("sandbox", {}) or {})
|
||||
if not bool(sandbox.get("allow_network", True)):
|
||||
wrapped.append("--unshare-net")
|
||||
wrapped.extend(args)
|
||||
meta["available"] = True
|
||||
return wrapped
|
||||
|
||||
|
||||
def _wrap_with_sandbox_exec(
|
||||
args: list[str],
|
||||
*,
|
||||
cwd: str,
|
||||
context: dict[str, Any],
|
||||
meta: dict[str, Any],
|
||||
) -> list[str]:
|
||||
sandbox = dict(context.get("sandbox", {}) or {})
|
||||
workspace = str(Path(context.get("workspace_root") or cwd).resolve())
|
||||
escaped_workspace = workspace.replace("\\", "\\\\").replace('"', '\\"')
|
||||
allow_network = bool(sandbox.get("allow_network", True))
|
||||
profile_lines = [
|
||||
"(version 1)",
|
||||
"(deny default)",
|
||||
"(import \"system.sb\")",
|
||||
"(allow process-exec)",
|
||||
"(allow process-fork)",
|
||||
"(allow signal (target self))",
|
||||
"(allow file-read*)",
|
||||
f"(allow file-write* (subpath \"{escaped_workspace}\") (subpath \"/tmp\") (subpath \"/private/tmp\"))",
|
||||
]
|
||||
if allow_network:
|
||||
profile_lines.append("(allow network*)")
|
||||
meta["available"] = True
|
||||
return ["sandbox-exec", "-p", "\n".join(profile_lines), *args]
|
||||
@@ -0,0 +1,765 @@
|
||||
"""File system operation tools with compatibility aliases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import difflib
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from opc.layer4_tools.output_budget import TextClip, clip_text
|
||||
from opc.layer4_tools.registry import ToolDefinition
|
||||
|
||||
|
||||
_MAX_TEXT_CHARS = 12_000
|
||||
_MAX_LIST_RESULTS = 500
|
||||
_MAX_SEARCH_MATCH_CHARS = 500
|
||||
_MAX_DIFF_CHARS = 16_000
|
||||
|
||||
|
||||
class PatchApplyError(RuntimeError):
|
||||
"""Raised when an apply_patch payload cannot be applied safely."""
|
||||
|
||||
|
||||
def _resolve_task_path(path: str, task: Any | None = None) -> Path:
|
||||
candidate = Path(path)
|
||||
if candidate.is_absolute():
|
||||
return candidate
|
||||
base_dir = ""
|
||||
if task is not None:
|
||||
metadata = getattr(task, "metadata", {}) or {}
|
||||
candidate_roots = [
|
||||
str(metadata.get("output_root", "") or "").strip(),
|
||||
str(metadata.get("target_output_dir", "") or "").strip(),
|
||||
str(metadata.get("workspace_root", "") or "").strip(),
|
||||
str(metadata.get("comms_workspace_root", "") or "").strip(),
|
||||
]
|
||||
for raw in candidate_roots:
|
||||
if not raw:
|
||||
continue
|
||||
resolved = Path(raw).expanduser()
|
||||
if resolved.exists():
|
||||
base_dir = str(resolved)
|
||||
break
|
||||
if not base_dir:
|
||||
base_dir = str(resolved)
|
||||
if base_dir:
|
||||
return Path(base_dir) / candidate
|
||||
return candidate
|
||||
|
||||
|
||||
def _safe_read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def _normalize_text(value: str) -> str:
|
||||
return value.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
|
||||
def _truncate_text(value: str, *, limit: int = _MAX_TEXT_CHARS) -> str:
|
||||
return clip_text(value, limit=limit, marker="truncated").text
|
||||
|
||||
|
||||
def _truncate_match_entry(value: str, *, limit: int = _MAX_SEARCH_MATCH_CHARS) -> str:
|
||||
return clip_text(value, limit=limit, marker="match truncated", prefer_newline=False).text.replace("\n", " ")
|
||||
|
||||
|
||||
def _build_diff_preview(old_text: str, new_text: str, path: str) -> TextClip:
|
||||
old_lines = _normalize_text(old_text).splitlines()
|
||||
new_lines = _normalize_text(new_text).splitlines()
|
||||
diff = difflib.unified_diff(
|
||||
old_lines,
|
||||
new_lines,
|
||||
fromfile=f"{path} (before)",
|
||||
tofile=f"{path} (after)",
|
||||
lineterm="",
|
||||
)
|
||||
return clip_text("\n".join(diff), limit=_MAX_DIFF_CHARS, marker="diff truncated")
|
||||
|
||||
|
||||
def _diff_fields(old_text: str, new_text: str, path: str) -> dict[str, Any]:
|
||||
preview = _build_diff_preview(old_text, new_text, path)
|
||||
return {
|
||||
"diff_preview": preview.text,
|
||||
"diff_truncated": preview.truncated,
|
||||
"diff_omitted_chars": preview.omitted_chars,
|
||||
"diff_original_chars": preview.original_chars,
|
||||
}
|
||||
|
||||
|
||||
def _render_file_slice(
|
||||
lines: list[str],
|
||||
*,
|
||||
offset: int,
|
||||
limit: int | None,
|
||||
include_line_numbers: bool,
|
||||
) -> dict[str, Any]:
|
||||
start = max(0, int(offset or 0))
|
||||
line_limit = int(limit) if limit and int(limit) > 0 else None
|
||||
end = start + line_limit if line_limit is not None else len(lines)
|
||||
selected = lines[start:end]
|
||||
rendered_lines = [
|
||||
f"{start + idx + 1}: {line}" if include_line_numbers else line
|
||||
for idx, line in enumerate(selected)
|
||||
]
|
||||
full_rendered = "".join(rendered_lines)
|
||||
if len(full_rendered) <= _MAX_TEXT_CHARS:
|
||||
returned_count = len(selected)
|
||||
return {
|
||||
"content": full_rendered,
|
||||
"truncated": False,
|
||||
"omitted_chars": 0,
|
||||
"returned_line_count": returned_count,
|
||||
"next_offset": start + returned_count if start + returned_count < len(lines) else None,
|
||||
}
|
||||
|
||||
kept: list[str] = []
|
||||
used = 0
|
||||
for rendered in rendered_lines:
|
||||
if used + len(rendered) > _MAX_TEXT_CHARS:
|
||||
if not kept:
|
||||
first = clip_text(rendered, limit=_MAX_TEXT_CHARS, marker="file_read line truncated")
|
||||
kept.append(first.text)
|
||||
break
|
||||
kept.append(rendered)
|
||||
used += len(rendered)
|
||||
returned_count = max(1, len(kept)) if rendered_lines else 0
|
||||
content = "".join(kept)
|
||||
if not content.endswith("]"):
|
||||
omitted = max(0, len(full_rendered) - len(content))
|
||||
content = content.rstrip() + f"\n[file_read truncated: {omitted} chars omitted]"
|
||||
return {
|
||||
"content": content,
|
||||
"truncated": True,
|
||||
"omitted_chars": max(0, len(full_rendered) - len("".join(kept))),
|
||||
"returned_line_count": returned_count,
|
||||
"next_offset": start + returned_count if start + returned_count < len(lines) else None,
|
||||
}
|
||||
|
||||
|
||||
def _iter_directory(root: Path, *, recursive: bool, max_depth: int) -> list[Path]:
|
||||
results: list[Path] = []
|
||||
|
||||
def _walk(current: Path, depth: int) -> None:
|
||||
if depth > max_depth:
|
||||
return
|
||||
try:
|
||||
entries = sorted(current.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower()))
|
||||
except (FileNotFoundError, PermissionError):
|
||||
return
|
||||
for item in entries:
|
||||
results.append(item)
|
||||
if recursive and item.is_dir():
|
||||
_walk(item, depth + 1)
|
||||
|
||||
_walk(root, 0)
|
||||
return results
|
||||
|
||||
|
||||
def _apply_text_patch(current_text: str, patch_lines: list[str], path: str) -> str:
|
||||
updated = _normalize_text(current_text)
|
||||
current_pos = 0
|
||||
chunk: list[str] = []
|
||||
|
||||
def _apply_chunk(buffer: list[str], working_text: str, cursor: int) -> tuple[str, int]:
|
||||
filtered = [line for line in buffer if line and line != "*** End of File"]
|
||||
if not filtered:
|
||||
return working_text, cursor
|
||||
search_lines = [line[1:] for line in filtered if line[:1] in {" ", "-"}]
|
||||
replace_lines = [line[1:] for line in filtered if line[:1] in {" ", "+"}]
|
||||
search_text = "\n".join(search_lines)
|
||||
replacement_text = "\n".join(replace_lines)
|
||||
if not search_text:
|
||||
raise PatchApplyError(f"Patch chunk for `{path}` is missing search context.")
|
||||
start = working_text.find(search_text, cursor)
|
||||
if start < 0:
|
||||
start = working_text.find(search_text)
|
||||
if start < 0:
|
||||
raise PatchApplyError(f"Unable to match patch chunk in `{path}`.")
|
||||
end = start + len(search_text)
|
||||
next_text = working_text[:start] + replacement_text + working_text[end:]
|
||||
return next_text, start + len(replacement_text)
|
||||
|
||||
for line in patch_lines:
|
||||
if line.startswith("@@"):
|
||||
updated, current_pos = _apply_chunk(chunk, updated, current_pos)
|
||||
chunk = []
|
||||
continue
|
||||
if line.startswith((" ", "+", "-")) or line == "*** End of File":
|
||||
chunk.append(line)
|
||||
continue
|
||||
raise PatchApplyError(f"Unsupported patch line in `{path}`: {line}")
|
||||
updated, current_pos = _apply_chunk(chunk, updated, current_pos)
|
||||
_ = current_pos
|
||||
return updated
|
||||
|
||||
|
||||
def _parse_patch_operations(patch: str) -> list[dict[str, Any]]:
|
||||
lines = patch.splitlines()
|
||||
if not lines or lines[0] != "*** Begin Patch":
|
||||
raise PatchApplyError("Patch must start with `*** Begin Patch`.")
|
||||
|
||||
operations: list[dict[str, Any]] = []
|
||||
index = 1
|
||||
while index < len(lines):
|
||||
line = lines[index]
|
||||
if line == "*** End Patch":
|
||||
return operations
|
||||
if line.startswith("*** Add File: "):
|
||||
path = line[len("*** Add File: "):].strip()
|
||||
index += 1
|
||||
content_lines: list[str] = []
|
||||
while index < len(lines) and not lines[index].startswith("*** "):
|
||||
payload = lines[index]
|
||||
if not payload.startswith("+"):
|
||||
raise PatchApplyError(f"Add File expects `+` lines only for `{path}`.")
|
||||
content_lines.append(payload[1:])
|
||||
index += 1
|
||||
operations.append({"kind": "add", "path": path, "content": "\n".join(content_lines)})
|
||||
continue
|
||||
if line.startswith("*** Delete File: "):
|
||||
path = line[len("*** Delete File: "):].strip()
|
||||
operations.append({"kind": "delete", "path": path})
|
||||
index += 1
|
||||
continue
|
||||
if line.startswith("*** Update File: "):
|
||||
path = line[len("*** Update File: "):].strip()
|
||||
index += 1
|
||||
move_to = None
|
||||
if index < len(lines) and lines[index].startswith("*** Move to: "):
|
||||
move_to = lines[index][len("*** Move to: "):].strip()
|
||||
index += 1
|
||||
patch_lines: list[str] = []
|
||||
while index < len(lines):
|
||||
candidate = lines[index]
|
||||
if candidate == "*** End Patch" or candidate.startswith("*** Add File: ") or candidate.startswith("*** Delete File: ") or candidate.startswith("*** Update File: "):
|
||||
break
|
||||
patch_lines.append(candidate)
|
||||
index += 1
|
||||
operations.append({"kind": "update", "path": path, "move_to": move_to, "patch_lines": patch_lines})
|
||||
continue
|
||||
raise PatchApplyError(f"Unsupported patch operation: {line}")
|
||||
raise PatchApplyError("Patch is missing `*** End Patch`.")
|
||||
|
||||
|
||||
async def file_read(
|
||||
path: str,
|
||||
offset: int = 0,
|
||||
limit: int | None = None,
|
||||
include_line_numbers: bool = False,
|
||||
task: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Read file contents."""
|
||||
p = _resolve_task_path(path, task)
|
||||
if not p.exists():
|
||||
return {"error": f"File not found: {path}", "success": False}
|
||||
if not p.is_file():
|
||||
return {"error": f"Not a file: {path}", "success": False}
|
||||
try:
|
||||
text = _safe_read_text(p)
|
||||
lines = text.splitlines(keepends=True)
|
||||
rendered = _render_file_slice(
|
||||
lines,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
include_line_numbers=include_line_numbers,
|
||||
)
|
||||
return {
|
||||
"content": rendered["content"],
|
||||
"total_lines": len(text.splitlines()),
|
||||
"returned_start_line": max(0, int(offset or 0)) + 1 if lines else 0,
|
||||
"returned_line_count": rendered["returned_line_count"],
|
||||
"next_offset": rendered["next_offset"],
|
||||
"truncated": rendered["truncated"],
|
||||
"omitted_chars": rendered["omitted_chars"],
|
||||
"path": str(p.resolve()),
|
||||
"success": True,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc), "success": False}
|
||||
|
||||
|
||||
async def file_write(path: str, content: str, create_dirs: bool = True, task: Any | None = None) -> dict[str, Any]:
|
||||
"""Write content to a file."""
|
||||
p = _resolve_task_path(path, task)
|
||||
existed = p.exists()
|
||||
before = _safe_read_text(p) if existed and p.is_file() else ""
|
||||
try:
|
||||
if create_dirs:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(content, encoding="utf-8")
|
||||
return {
|
||||
"success": True,
|
||||
"path": str(p.resolve()),
|
||||
"bytes_written": len(content.encode("utf-8")),
|
||||
"created": not existed,
|
||||
**_diff_fields(before, content, str(p)),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc), "success": False}
|
||||
|
||||
|
||||
async def file_edit(
|
||||
path: str,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
replace_all: bool = False,
|
||||
task: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Replace a specific string in a file."""
|
||||
p = _resolve_task_path(path, task)
|
||||
if not p.exists():
|
||||
return {"error": f"File not found: {path}", "success": False}
|
||||
try:
|
||||
text = _safe_read_text(p)
|
||||
count = text.count(old_string)
|
||||
if count == 0:
|
||||
return {"error": "old_string not found in file", "success": False}
|
||||
if count > 1 and not replace_all:
|
||||
return {"error": f"old_string found {count} times; add more context or use replace_all=true.", "success": False}
|
||||
updated = text.replace(old_string, new_string) if replace_all else text.replace(old_string, new_string, 1)
|
||||
p.write_text(updated, encoding="utf-8")
|
||||
return {
|
||||
"success": True,
|
||||
"path": str(p.resolve()),
|
||||
"replacements": count if replace_all else 1,
|
||||
**_diff_fields(text, updated, str(p)),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc), "success": False}
|
||||
|
||||
|
||||
async def apply_patch(patch: str, task: Any | None = None) -> dict[str, Any]:
|
||||
"""Apply an OpenAI-style patch document to one or more files."""
|
||||
try:
|
||||
operations = _parse_patch_operations(patch)
|
||||
except PatchApplyError as exc:
|
||||
return {"error": str(exc), "success": False}
|
||||
|
||||
changed: list[dict[str, Any]] = []
|
||||
try:
|
||||
for operation in operations:
|
||||
kind = operation["kind"]
|
||||
raw_path = str(operation["path"])
|
||||
path = _resolve_task_path(raw_path, task)
|
||||
if kind == "add":
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
new_text = str(operation["content"])
|
||||
path.write_text(new_text, encoding="utf-8")
|
||||
changed.append({
|
||||
"kind": "add",
|
||||
"path": str(path.resolve()),
|
||||
**_diff_fields("", new_text, str(path)),
|
||||
})
|
||||
continue
|
||||
if kind == "delete":
|
||||
if not path.exists():
|
||||
raise PatchApplyError(f"Cannot delete missing file `{raw_path}`.")
|
||||
before = _safe_read_text(path) if path.is_file() else ""
|
||||
path.unlink()
|
||||
changed.append({
|
||||
"kind": "delete",
|
||||
"path": str(path.resolve()),
|
||||
**_diff_fields(before, "", str(path)),
|
||||
})
|
||||
continue
|
||||
if kind == "update":
|
||||
if not path.exists():
|
||||
raise PatchApplyError(f"Cannot update missing file `{raw_path}`.")
|
||||
before = _safe_read_text(path)
|
||||
updated = _apply_text_patch(before, list(operation.get("patch_lines", [])), raw_path)
|
||||
target = _resolve_task_path(str(operation.get("move_to") or raw_path), task)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target != path:
|
||||
path.unlink()
|
||||
target.write_text(updated, encoding="utf-8")
|
||||
changed.append({
|
||||
"kind": "update",
|
||||
"path": str(target.resolve()),
|
||||
"moved_from": str(path.resolve()) if target != path else "",
|
||||
**_diff_fields(before, updated, str(target)),
|
||||
})
|
||||
continue
|
||||
return {
|
||||
"success": True,
|
||||
"changed_files": changed,
|
||||
"applied_operations": len(changed),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc), "success": False}
|
||||
|
||||
|
||||
async def glob(
|
||||
pattern: str,
|
||||
path: str = ".",
|
||||
recursive: bool = True,
|
||||
include_dirs: bool = False,
|
||||
max_results: int = 200,
|
||||
task: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return files matching a glob pattern."""
|
||||
root = _resolve_task_path(path, task)
|
||||
if not root.exists():
|
||||
return {"error": f"Directory not found: {path}", "success": False}
|
||||
iterator = root.rglob(pattern) if recursive else root.glob(pattern)
|
||||
entries: list[str] = []
|
||||
for item in iterator:
|
||||
if item.is_dir() and not include_dirs:
|
||||
continue
|
||||
try:
|
||||
entries.append(str(item.relative_to(root)))
|
||||
except ValueError:
|
||||
entries.append(str(item))
|
||||
if len(entries) >= max_results:
|
||||
break
|
||||
return {
|
||||
"success": True,
|
||||
"path": str(root.resolve()),
|
||||
"pattern": pattern,
|
||||
"entries": entries,
|
||||
"count": len(entries),
|
||||
}
|
||||
|
||||
|
||||
async def grep(
|
||||
query: str,
|
||||
path: str = ".",
|
||||
file_glob: str = "*",
|
||||
max_results: int = 200,
|
||||
offset: int = 0,
|
||||
head_limit: int | None = None,
|
||||
output_mode: str = "content",
|
||||
case_sensitive: bool = False,
|
||||
context_before: int = 0,
|
||||
context_after: int = 0,
|
||||
task: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Search file contents for a regex or fixed-string query."""
|
||||
root = _resolve_task_path(path, task)
|
||||
if not root.exists():
|
||||
return {"error": f"Directory not found: {path}", "success": False}
|
||||
applied_offset = max(0, int(offset or 0))
|
||||
effective_limit = max_results if head_limit is None else int(head_limit)
|
||||
|
||||
def _apply_pagination(items: list[str]) -> tuple[list[str], bool, int | None]:
|
||||
if effective_limit == 0:
|
||||
paged = items[applied_offset:]
|
||||
return paged, False, None
|
||||
limit_value = max(0, effective_limit)
|
||||
paged = items[applied_offset:applied_offset + limit_value]
|
||||
truncated = applied_offset + limit_value < len(items)
|
||||
next_offset = applied_offset + limit_value if truncated else None
|
||||
return paged, truncated, next_offset
|
||||
|
||||
def _format_output(all_matches: list[str]) -> dict[str, Any]:
|
||||
mode = str(output_mode or "content").strip() or "content"
|
||||
if mode not in {"content", "files_with_matches", "count"}:
|
||||
mode = "content"
|
||||
items = all_matches
|
||||
if mode == "files_with_matches":
|
||||
seen: set[str] = set()
|
||||
files: list[str] = []
|
||||
for entry in all_matches:
|
||||
file_part = entry.split(":", 1)[0]
|
||||
if file_part not in seen:
|
||||
seen.add(file_part)
|
||||
files.append(file_part)
|
||||
items = files
|
||||
elif mode == "count":
|
||||
counts: dict[str, int] = {}
|
||||
for entry in all_matches:
|
||||
file_part = entry.split(":", 1)[0]
|
||||
counts[file_part] = counts.get(file_part, 0) + 1
|
||||
items = [f"{name}: {count}" for name, count in sorted(counts.items())]
|
||||
paged, truncated, next_offset = _apply_pagination(items)
|
||||
clipped = [_truncate_match_entry(line) for line in paged]
|
||||
result: dict[str, Any] = {
|
||||
"success": True,
|
||||
"path": str(root.resolve()),
|
||||
"query": query,
|
||||
"output_mode": mode,
|
||||
"matches": clipped,
|
||||
"count": len(clipped),
|
||||
"total_count": len(items),
|
||||
"truncated": truncated,
|
||||
"next_offset": next_offset,
|
||||
"applied_offset": applied_offset,
|
||||
}
|
||||
if truncated and effective_limit != 0:
|
||||
result["applied_limit"] = max(0, effective_limit)
|
||||
if mode == "files_with_matches":
|
||||
result["files"] = clipped
|
||||
result["num_files"] = len(items)
|
||||
if mode == "count":
|
||||
result["counts"] = clipped
|
||||
return result
|
||||
|
||||
rg = shutil.which("rg")
|
||||
if rg:
|
||||
args = [rg, "--no-heading", "--line-number", "--color", "never", "--glob", file_glob]
|
||||
if case_sensitive:
|
||||
args.append("--case-sensitive")
|
||||
else:
|
||||
args.append("--ignore-case")
|
||||
if context_before:
|
||||
args.extend(["-B", str(context_before)])
|
||||
if context_after:
|
||||
args.extend(["-A", str(context_after)])
|
||||
args.extend([query, str(root)])
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
|
||||
output = stdout.decode("utf-8", errors="replace").splitlines()
|
||||
if proc.returncode not in (0, 1):
|
||||
return {"error": stderr.decode("utf-8", errors="replace"), "success": False}
|
||||
return _format_output(output)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
flags = 0 if case_sensitive else re.IGNORECASE
|
||||
pattern = re.compile(query, flags=flags)
|
||||
matches: list[str] = []
|
||||
for candidate in root.rglob(file_glob):
|
||||
if not candidate.is_file():
|
||||
continue
|
||||
try:
|
||||
lines = _safe_read_text(candidate).splitlines()
|
||||
except Exception:
|
||||
continue
|
||||
for line_index, line in enumerate(lines):
|
||||
if not pattern.search(line):
|
||||
continue
|
||||
start = max(0, line_index - max(0, context_before))
|
||||
end = min(len(lines), line_index + max(0, context_after) + 1)
|
||||
context_lines = []
|
||||
for idx in range(start, end):
|
||||
context_lines.append(
|
||||
_truncate_match_entry(f"{candidate.relative_to(root)}:{idx + 1}:{lines[idx]}")
|
||||
)
|
||||
matches.extend(context_lines)
|
||||
return _format_output(matches)
|
||||
|
||||
|
||||
async def file_search(
|
||||
pattern: str,
|
||||
directory: str = ".",
|
||||
file_glob: str = "*",
|
||||
max_results: int = 50,
|
||||
offset: int = 0,
|
||||
head_limit: int | None = None,
|
||||
task: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Compatibility wrapper for grep."""
|
||||
result = await grep(
|
||||
query=pattern,
|
||||
path=directory,
|
||||
file_glob=file_glob,
|
||||
max_results=max_results,
|
||||
offset=offset,
|
||||
head_limit=head_limit,
|
||||
task=task,
|
||||
)
|
||||
if result.get("success"):
|
||||
result["matches"] = list(result.get("matches", []))
|
||||
return result
|
||||
|
||||
|
||||
async def list_dir(path: str = ".", recursive: bool = False, max_depth: int = 3, task: Any | None = None) -> dict[str, Any]:
|
||||
"""List directory contents."""
|
||||
root = _resolve_task_path(path, task)
|
||||
if not root.exists():
|
||||
return {"error": f"Directory not found: {path}", "success": False}
|
||||
if not root.is_dir():
|
||||
return {"error": f"Not a directory: {path}", "success": False}
|
||||
|
||||
entries: list[str] = []
|
||||
items: list[dict[str, Any]] = []
|
||||
for item in _iter_directory(root, recursive=recursive, max_depth=max_depth):
|
||||
try:
|
||||
relative = str(item.relative_to(root))
|
||||
except ValueError:
|
||||
relative = str(item)
|
||||
entry_path = relative + ("/" if item.is_dir() else "")
|
||||
entries.append(entry_path)
|
||||
items.append({
|
||||
"path": relative + ("/" if item.is_dir() else ""),
|
||||
"is_dir": item.is_dir(),
|
||||
"size": item.stat().st_size if item.is_file() else 0,
|
||||
})
|
||||
if len(items) >= _MAX_LIST_RESULTS:
|
||||
break
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": str(root.resolve()),
|
||||
"entries": entries,
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
}
|
||||
|
||||
|
||||
def create_file_tools() -> list[ToolDefinition]:
|
||||
return [
|
||||
ToolDefinition(
|
||||
name="file_read",
|
||||
description="Read the contents of a file. Supports line offset, line limit, and optional line numbers.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Path to the file"},
|
||||
"offset": {"type": "integer", "description": "Line offset (0-based)", "default": 0},
|
||||
"limit": {"type": "integer", "description": "Maximum lines to read"},
|
||||
"include_line_numbers": {"type": "boolean", "description": "Prefix output with line numbers", "default": False},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
func=file_read,
|
||||
category="file",
|
||||
concurrency_safe=True,
|
||||
read_only=True,
|
||||
self_bounded_output=True,
|
||||
max_result_chars=80_000,
|
||||
),
|
||||
ToolDefinition(
|
||||
name="file_write",
|
||||
description="Write content to a file and return a diff preview against the previous content.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Path to the file"},
|
||||
"content": {"type": "string", "description": "Content to write"},
|
||||
"create_dirs": {"type": "boolean", "description": "Create parent directories if needed", "default": True},
|
||||
},
|
||||
"required": ["path", "content"],
|
||||
},
|
||||
func=file_write,
|
||||
category="file",
|
||||
concurrency_safe=False,
|
||||
read_only=False,
|
||||
),
|
||||
ToolDefinition(
|
||||
name="file_edit",
|
||||
description="Replace a specific string in a file and return a diff preview.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Path to the file"},
|
||||
"old_string": {"type": "string", "description": "Exact string to find"},
|
||||
"new_string": {"type": "string", "description": "Replacement string"},
|
||||
"replace_all": {"type": "boolean", "description": "Replace all occurrences instead of requiring uniqueness", "default": False},
|
||||
},
|
||||
"required": ["path", "old_string", "new_string"],
|
||||
},
|
||||
func=file_edit,
|
||||
category="file",
|
||||
concurrency_safe=False,
|
||||
read_only=False,
|
||||
),
|
||||
ToolDefinition(
|
||||
name="apply_patch",
|
||||
description="Apply an OpenAI-style patch document to one or more files.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"patch": {"type": "string", "description": "Patch document beginning with `*** Begin Patch`"},
|
||||
},
|
||||
"required": ["patch"],
|
||||
},
|
||||
func=apply_patch,
|
||||
category="file",
|
||||
concurrency_safe=False,
|
||||
read_only=False,
|
||||
),
|
||||
ToolDefinition(
|
||||
name="grep",
|
||||
description="Search file contents using ripgrep when available, with a Python fallback.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Regex or fixed-string query"},
|
||||
"path": {"type": "string", "description": "Directory to search", "default": "."},
|
||||
"file_glob": {"type": "string", "description": "File glob pattern", "default": "*"},
|
||||
"max_results": {"type": "integer", "description": "Maximum returned matches", "default": 200},
|
||||
"offset": {"type": "integer", "description": "Skip this many results before returning matches", "default": 0},
|
||||
"head_limit": {"type": "integer", "description": "Maximum returned entries; 0 means unlimited", "default": 200},
|
||||
"output_mode": {
|
||||
"type": "string",
|
||||
"description": "Return matching lines (`content`), unique files (`files_with_matches`), or per-file counts (`count`)",
|
||||
"default": "content",
|
||||
},
|
||||
"case_sensitive": {"type": "boolean", "description": "Use case-sensitive matching", "default": False},
|
||||
"context_before": {"type": "integer", "description": "Context lines before each hit", "default": 0},
|
||||
"context_after": {"type": "integer", "description": "Context lines after each hit", "default": 0},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
func=grep,
|
||||
category="file",
|
||||
concurrency_safe=True,
|
||||
read_only=True,
|
||||
self_bounded_output=True,
|
||||
max_result_chars=80_000,
|
||||
),
|
||||
ToolDefinition(
|
||||
name="glob",
|
||||
description="Return files matching a glob pattern.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {"type": "string", "description": "Glob pattern to match"},
|
||||
"path": {"type": "string", "description": "Directory to scan", "default": "."},
|
||||
"recursive": {"type": "boolean", "description": "Search recursively", "default": True},
|
||||
"include_dirs": {"type": "boolean", "description": "Include directories in the result set", "default": False},
|
||||
"max_results": {"type": "integer", "description": "Maximum entries to return", "default": 200},
|
||||
},
|
||||
"required": ["pattern"],
|
||||
},
|
||||
func=glob,
|
||||
category="file",
|
||||
concurrency_safe=True,
|
||||
read_only=True,
|
||||
),
|
||||
ToolDefinition(
|
||||
name="file_search",
|
||||
description="Compatibility alias for `grep`.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {"type": "string", "description": "Regex pattern to search"},
|
||||
"directory": {"type": "string", "description": "Directory to search in", "default": "."},
|
||||
"file_glob": {"type": "string", "description": "File glob pattern", "default": "*"},
|
||||
"max_results": {"type": "integer", "description": "Maximum results", "default": 50},
|
||||
"offset": {"type": "integer", "description": "Skip this many results before returning matches", "default": 0},
|
||||
"head_limit": {"type": "integer", "description": "Maximum returned entries; 0 means unlimited"},
|
||||
},
|
||||
"required": ["pattern"],
|
||||
},
|
||||
func=file_search,
|
||||
category="file",
|
||||
concurrency_safe=True,
|
||||
read_only=True,
|
||||
),
|
||||
ToolDefinition(
|
||||
name="list_dir",
|
||||
description="List directory contents. Optionally recurse up to a maximum depth.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Directory path", "default": "."},
|
||||
"recursive": {"type": "boolean", "description": "List recursively", "default": False},
|
||||
"max_depth": {"type": "integer", "description": "Maximum recursion depth", "default": 3},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
func=list_dir,
|
||||
category="file",
|
||||
concurrency_safe=True,
|
||||
read_only=True,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Git operation tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from opc.layer4_tools.shell import shell_exec
|
||||
from opc.layer4_tools.registry import ToolDefinition
|
||||
|
||||
|
||||
async def git_status(working_directory: str = ".") -> dict[str, Any]:
|
||||
return await shell_exec("git status --porcelain && echo '---' && git log --oneline -5", working_directory=working_directory)
|
||||
|
||||
|
||||
async def git_commit(message: str, working_directory: str = ".", add_all: bool = True) -> dict[str, Any]:
|
||||
cmds = []
|
||||
if add_all:
|
||||
cmds.append("git add -A")
|
||||
cmds.append(f'git commit -m "{message}"')
|
||||
return await shell_exec(" && ".join(cmds), working_directory=working_directory)
|
||||
|
||||
|
||||
async def git_diff(working_directory: str = ".", staged: bool = False) -> dict[str, Any]:
|
||||
cmd = "git diff --staged" if staged else "git diff"
|
||||
return await shell_exec(cmd, working_directory=working_directory)
|
||||
|
||||
|
||||
async def git_clone(url: str, directory: str = ".") -> dict[str, Any]:
|
||||
return await shell_exec(f"git clone {url}", working_directory=directory, timeout=300)
|
||||
|
||||
|
||||
def create_git_tools() -> list[ToolDefinition]:
|
||||
return [
|
||||
ToolDefinition(
|
||||
name="git_status",
|
||||
description="Show git status and recent commits.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"working_directory": {"type": "string", "description": "Git repo directory", "default": "."},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
func=git_status,
|
||||
category="code",
|
||||
),
|
||||
ToolDefinition(
|
||||
name="git_commit",
|
||||
description="Stage all changes and commit with a message.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {"type": "string", "description": "Commit message"},
|
||||
"working_directory": {"type": "string", "description": "Git repo directory", "default": "."},
|
||||
"add_all": {"type": "boolean", "description": "Stage all changes", "default": True},
|
||||
},
|
||||
"required": ["message"],
|
||||
},
|
||||
func=git_commit,
|
||||
category="code",
|
||||
requires_confirmation=True,
|
||||
),
|
||||
ToolDefinition(
|
||||
name="git_diff",
|
||||
description="Show git diff (staged or unstaged).",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"working_directory": {"type": "string", "description": "Git repo directory", "default": "."},
|
||||
"staged": {"type": "boolean", "description": "Show staged diff", "default": False},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
func=git_diff,
|
||||
category="code",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Shared helpers for recoverable tool-output previews."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from opc.core.config import get_opc_home
|
||||
from opc.layer4_tools.execution_context import resolve_task_execution_context
|
||||
|
||||
|
||||
DEFAULT_TRUNCATION_MARKER = "truncated"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextClip:
|
||||
text: str
|
||||
truncated: bool
|
||||
omitted_chars: int
|
||||
original_chars: int
|
||||
kept_chars: int
|
||||
|
||||
|
||||
def clip_text(
|
||||
value: Any,
|
||||
*,
|
||||
limit: int,
|
||||
marker: str = DEFAULT_TRUNCATION_MARKER,
|
||||
prefer_newline: bool = True,
|
||||
) -> TextClip:
|
||||
"""Return a marked preview of ``value`` within a character budget."""
|
||||
text = str(value or "")
|
||||
original_chars = len(text)
|
||||
limit = max(0, int(limit or 0))
|
||||
if original_chars <= limit:
|
||||
return TextClip(
|
||||
text=text,
|
||||
truncated=False,
|
||||
omitted_chars=0,
|
||||
original_chars=original_chars,
|
||||
kept_chars=original_chars,
|
||||
)
|
||||
if limit <= 0:
|
||||
kept = ""
|
||||
else:
|
||||
kept = text[:limit]
|
||||
if prefer_newline and "\n" in kept:
|
||||
floor = max(1, int(limit * 0.80))
|
||||
boundary = kept.rfind("\n", floor)
|
||||
if boundary > 0:
|
||||
kept = kept[:boundary]
|
||||
kept = kept.rstrip()
|
||||
omitted = original_chars - len(kept)
|
||||
marker_text = marker.strip("[]") or DEFAULT_TRUNCATION_MARKER
|
||||
suffix = f"[{marker_text}: {omitted} chars omitted]"
|
||||
rendered = f"{kept}\n{suffix}" if kept else suffix
|
||||
return TextClip(
|
||||
text=rendered,
|
||||
truncated=True,
|
||||
omitted_chars=omitted,
|
||||
original_chars=original_chars,
|
||||
kept_chars=len(kept),
|
||||
)
|
||||
|
||||
|
||||
def truncation_metadata(clip: TextClip, *, prefix: str = "") -> dict[str, Any]:
|
||||
"""Render stable metadata keys for a text clip."""
|
||||
return {
|
||||
f"{prefix}truncated": clip.truncated,
|
||||
f"{prefix}omitted_chars": clip.omitted_chars,
|
||||
f"{prefix}original_chars": clip.original_chars,
|
||||
}
|
||||
|
||||
|
||||
def _safe_segment(value: str, fallback: str) -> str:
|
||||
segment = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(value or "").strip()).strip(".-")
|
||||
return segment[:80] or fallback
|
||||
|
||||
|
||||
def _tool_results_root(task: Any | None) -> Path:
|
||||
context = resolve_task_execution_context(task)
|
||||
comms_root = str(context.get("comms_root", "") or "").strip()
|
||||
if comms_root:
|
||||
return Path(comms_root).expanduser().resolve() / "tool-results"
|
||||
return get_opc_home() / "artifacts" / "tool-results"
|
||||
|
||||
|
||||
def persist_tool_result(
|
||||
content: Any,
|
||||
*,
|
||||
tool_name: str,
|
||||
task: Any | None = None,
|
||||
extension: str = "json",
|
||||
) -> dict[str, Any]:
|
||||
"""Persist full tool output and return path/size metadata."""
|
||||
if isinstance(content, str):
|
||||
text = content
|
||||
extension = extension or "txt"
|
||||
else:
|
||||
text = json.dumps(content, ensure_ascii=False, indent=2, default=str)
|
||||
extension = extension or "json"
|
||||
|
||||
task_id = str(getattr(task, "id", "") or "").strip()
|
||||
session_id = str(getattr(task, "session_id", "") or getattr(task, "parent_session_id", "") or "").strip()
|
||||
bucket = _safe_segment(task_id or session_id or "global", "global")
|
||||
root = _tool_results_root(task) / bucket
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
|
||||
digest = sha256(text.encode("utf-8", errors="replace")).hexdigest()[:12]
|
||||
suffix = extension.lstrip(".") or "txt"
|
||||
path = root / f"{_safe_segment(tool_name, 'tool')}-{stamp}-{digest}.{suffix}"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return {
|
||||
"full_output_path": str(path),
|
||||
"original_size_chars": len(text),
|
||||
}
|
||||
|
||||
|
||||
def _json_size(value: Any) -> int:
|
||||
return len(json.dumps(value, ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
def budget_tool_output(
|
||||
output: dict[str, Any],
|
||||
*,
|
||||
tool_name: str,
|
||||
task: Any | None = None,
|
||||
max_chars: int,
|
||||
preview_chars: int | None = None,
|
||||
persist_large_results: bool = True,
|
||||
self_bounded_output: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a recoverable registry-level budget to a tool output."""
|
||||
max_chars = max(1, int(max_chars or 1))
|
||||
try:
|
||||
serialized = json.dumps(output, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return output
|
||||
if len(serialized) <= max_chars:
|
||||
return output
|
||||
|
||||
preview_limit = max(400, int(preview_chars or max_chars // 2))
|
||||
persisted: dict[str, Any] = {}
|
||||
if persist_large_results and not self_bounded_output:
|
||||
persisted = persist_tool_result(output, tool_name=tool_name, task=task, extension="json")
|
||||
|
||||
meta = {
|
||||
"truncated": True,
|
||||
"omitted_chars": max(0, len(serialized) - max_chars),
|
||||
"original_size_chars": len(serialized),
|
||||
**persisted,
|
||||
}
|
||||
result_val = output.get("result")
|
||||
|
||||
if isinstance(result_val, dict):
|
||||
preview_result = json.loads(json.dumps(result_val, ensure_ascii=False, default=str))
|
||||
for key in ("stdout", "stderr", "content", "rendered", "summary", "diff_preview"):
|
||||
value = preview_result.get(key)
|
||||
if isinstance(value, str):
|
||||
clip = clip_text(
|
||||
value,
|
||||
limit=preview_limit,
|
||||
marker=f"{tool_name} {key} truncated",
|
||||
)
|
||||
preview_result[key] = clip.text
|
||||
if clip.truncated:
|
||||
preview_result[f"{key}_truncated"] = True
|
||||
preview_result[f"{key}_omitted_chars"] = clip.omitted_chars
|
||||
preview_result.update({key: value for key, value in meta.items() if value not in ("", None)})
|
||||
preview_output = {**output, "result": preview_result, **meta}
|
||||
if _json_size(preview_output) <= max_chars:
|
||||
return preview_output
|
||||
|
||||
clip = clip_text(serialized, limit=preview_limit, marker=f"{tool_name} result truncated")
|
||||
return {
|
||||
"result": {
|
||||
"preview": clip.text,
|
||||
**{key: value for key, value in meta.items() if value not in ("", None)},
|
||||
},
|
||||
"success": output.get("success", True),
|
||||
**meta,
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Python code execution tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from opc.layer4_tools.execution_context import (
|
||||
build_subprocess_env,
|
||||
resolve_python_executable,
|
||||
resolve_task_execution_context,
|
||||
wrap_command_for_context,
|
||||
)
|
||||
from opc.layer4_tools.output_budget import clip_text, persist_tool_result
|
||||
from opc.layer4_tools.registry import ToolDefinition
|
||||
|
||||
|
||||
async def python_exec(
|
||||
code: str,
|
||||
timeout: int = 60,
|
||||
task: Any | None = None,
|
||||
on_progress: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute Python code in a subprocess and capture output."""
|
||||
_ = on_progress
|
||||
context = resolve_task_execution_context(task)
|
||||
workspace_root = str(context.get("workspace_root", "") or "").strip()
|
||||
temp_dir = workspace_root if workspace_root and Path(workspace_root).exists() else None
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, dir=temp_dir) as f:
|
||||
f.write(code)
|
||||
tmp_path = f.name
|
||||
|
||||
proc: asyncio.subprocess.Process | None = None
|
||||
try:
|
||||
executable = resolve_python_executable(context)
|
||||
cwd = workspace_root or os.getcwd()
|
||||
env = build_subprocess_env(context)
|
||||
wrapped_args, sandbox_meta = wrap_command_for_context(
|
||||
[executable, tmp_path],
|
||||
cwd=str(Path(cwd).resolve()),
|
||||
context=context,
|
||||
)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*wrapped_args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(Path(cwd).resolve()),
|
||||
env=env,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
stdout_text = stdout.decode("utf-8", errors="replace")
|
||||
stderr_text = stderr.decode("utf-8", errors="replace")
|
||||
stdout_clip = clip_text(stdout_text, limit=30000, marker="python stdout truncated")
|
||||
stderr_clip = clip_text(stderr_text, limit=10000, marker="python stderr truncated")
|
||||
stdout_persisted = (
|
||||
persist_tool_result(stdout_text, tool_name="python_exec_stdout", task=task, extension="txt")
|
||||
if stdout_clip.truncated else {}
|
||||
)
|
||||
stderr_persisted = (
|
||||
persist_tool_result(stderr_text, tool_name="python_exec_stderr", task=task, extension="txt")
|
||||
if stderr_clip.truncated else {}
|
||||
)
|
||||
return {
|
||||
"stdout": stdout_clip.text,
|
||||
"stderr": stderr_clip.text,
|
||||
"stdout_truncated": stdout_clip.truncated,
|
||||
"stdout_omitted_chars": stdout_clip.omitted_chars,
|
||||
"stderr_truncated": stderr_clip.truncated,
|
||||
"stderr_omitted_chars": stderr_clip.omitted_chars,
|
||||
"full_stdout_path": stdout_persisted.get("full_output_path", ""),
|
||||
"full_stderr_path": stderr_persisted.get("full_output_path", ""),
|
||||
"exit_code": proc.returncode,
|
||||
"sandbox": sandbox_meta,
|
||||
"execution_context": {
|
||||
"workspace_root": workspace_root,
|
||||
"venv_path": str(context.get("venv_path", "") or ""),
|
||||
"python_executable": executable,
|
||||
"preparation_error": str(context.get("preparation_error", "") or ""),
|
||||
"sandbox": dict(context.get("sandbox", {}) or {}),
|
||||
},
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
if proc is not None:
|
||||
proc.kill()
|
||||
return {
|
||||
"error": f"Execution timed out after {timeout}s",
|
||||
"exit_code": -1,
|
||||
"execution_context": {
|
||||
"workspace_root": workspace_root,
|
||||
"venv_path": str(context.get("venv_path", "") or ""),
|
||||
"python_executable": resolve_python_executable(context),
|
||||
"preparation_error": str(context.get("preparation_error", "") or ""),
|
||||
},
|
||||
}
|
||||
except RuntimeError as exc:
|
||||
return {
|
||||
"error": str(exc),
|
||||
"exit_code": -1,
|
||||
"execution_context": {
|
||||
"workspace_root": workspace_root,
|
||||
"venv_path": str(context.get("venv_path", "") or ""),
|
||||
"python_executable": resolve_python_executable(context),
|
||||
"preparation_error": str(context.get("preparation_error", "") or ""),
|
||||
"sandbox": dict(context.get("sandbox", {}) or {}),
|
||||
},
|
||||
}
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def create_python_tool() -> ToolDefinition:
|
||||
return ToolDefinition(
|
||||
name="python_exec",
|
||||
description="Execute Python code and return stdout/stderr. Use for data processing, calculations, or testing.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "description": "Python code to execute"},
|
||||
"timeout": {"type": "integer", "description": "Timeout in seconds", "default": 60},
|
||||
},
|
||||
"required": ["code"],
|
||||
},
|
||||
func=python_exec,
|
||||
category="compute",
|
||||
self_bounded_output=True,
|
||||
max_result_chars=80_000,
|
||||
)
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Tool registry — central registry for all tools available to agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import traceback
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.layer4_tools.output_budget import budget_tool_output
|
||||
|
||||
# Maximum serialized tool output size (characters). Outputs exceeding this
|
||||
# limit are previewed before being returned to the agent loop; recoverable
|
||||
# tools persist full output to disk.
|
||||
_OUTPUT_LIMIT = 20_000
|
||||
|
||||
|
||||
ToolFunc = Callable[..., Coroutine[Any, Any, Any]]
|
||||
|
||||
_PARAM_ALIASES: dict[str, str] = {
|
||||
"cmd": "command",
|
||||
"dir": "working_directory",
|
||||
"cwd": "working_directory",
|
||||
"directory": "working_directory",
|
||||
"pattern": "query",
|
||||
"search_query": "query",
|
||||
"search_term": "query",
|
||||
"keyword": "query",
|
||||
"filepath": "file_path",
|
||||
"filename": "file_path",
|
||||
"file": "file_path",
|
||||
"text": "content",
|
||||
"body": "content",
|
||||
}
|
||||
|
||||
|
||||
class ToolDefinition:
|
||||
"""Metadata and callable for a single tool."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
parameters: dict[str, Any],
|
||||
func: ToolFunc,
|
||||
category: str = "general",
|
||||
requires_confirmation: bool = False,
|
||||
concurrency_safe: bool | None = None,
|
||||
read_only: bool | None = None,
|
||||
runtime_managed: bool = False,
|
||||
max_result_chars: int = _OUTPUT_LIMIT,
|
||||
persist_large_results: bool = True,
|
||||
self_bounded_output: bool = False,
|
||||
preview_chars: int | None = None,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.parameters = parameters
|
||||
self.func = func
|
||||
self.category = category
|
||||
self.requires_confirmation = requires_confirmation
|
||||
self.concurrency_safe = concurrency_safe
|
||||
self.read_only = read_only
|
||||
self.runtime_managed = runtime_managed
|
||||
self.max_result_chars = max_result_chars
|
||||
self.persist_large_results = persist_large_results
|
||||
self.self_bounded_output = self_bounded_output
|
||||
self.preview_chars = preview_chars
|
||||
|
||||
def to_schema(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": self.parameters,
|
||||
}
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Manages all available tools and dispatches execution."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tools: dict[str, ToolDefinition] = {}
|
||||
self._approval_callback: Any = None
|
||||
|
||||
def register(self, tool: ToolDefinition) -> None:
|
||||
self._tools[tool.name] = tool
|
||||
logger.debug(f"Tool registered: {tool.name} [{tool.category}]")
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""Remove a tool by name. No-op if not found."""
|
||||
if self._tools.pop(name, None):
|
||||
logger.debug(f"Tool unregistered: {name}")
|
||||
|
||||
def get(self, name: str) -> ToolDefinition | None:
|
||||
return self._tools.get(name)
|
||||
|
||||
def list_tools(self, category: str | None = None, allowed: list[str] | None = None) -> list[ToolDefinition]:
|
||||
tools = list(self._tools.values())
|
||||
if category:
|
||||
tools = [t for t in tools if t.category == category]
|
||||
if allowed:
|
||||
tools = [t for t in tools if t.name in allowed]
|
||||
return tools
|
||||
|
||||
def get_schemas(self, allowed: list[str] | None = None) -> list[dict[str, Any]]:
|
||||
tools = self.list_tools(allowed=allowed)
|
||||
return [t.to_schema() for t in tools]
|
||||
|
||||
def set_approval_callback(self, callback: Any) -> None:
|
||||
self._approval_callback = callback
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
name: str,
|
||||
arguments: dict[str, Any],
|
||||
task: Any = None,
|
||||
on_progress: Any = None,
|
||||
skip_approval: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
tool = self._tools.get(name)
|
||||
if not tool:
|
||||
return {"error": f"Unknown tool: {name}", "success": False}
|
||||
|
||||
if self._approval_callback and not skip_approval:
|
||||
allowed, decision = await self._approval_callback(tool, arguments, task, on_progress)
|
||||
if not allowed:
|
||||
return {
|
||||
"error": f"Tool execution blocked by autonomy policy: {decision.rationale}",
|
||||
"approval": {
|
||||
"action": decision.action.value,
|
||||
"risk_level": decision.risk_level.value,
|
||||
"confidence": decision.confidence,
|
||||
"policy_source": decision.policy_source,
|
||||
"rationale": decision.rationale,
|
||||
**dict(decision.metadata or {}),
|
||||
},
|
||||
"success": False,
|
||||
}
|
||||
|
||||
return await self.invoke(name, arguments, task=task, on_progress=on_progress)
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
name: str,
|
||||
arguments: dict[str, Any],
|
||||
task: Any = None,
|
||||
on_progress: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
tool = self._tools.get(name)
|
||||
if not tool:
|
||||
return {"error": f"Unknown tool: {name}", "success": False}
|
||||
|
||||
try:
|
||||
call_args = self._prepare_call_args(tool, arguments, task=task, on_progress=on_progress)
|
||||
result = await tool.func(**call_args)
|
||||
output = {"result": result, "success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Tool {name} failed ({type(e).__name__}): {e}", exc_info=True)
|
||||
output = {
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc(),
|
||||
"success": False,
|
||||
}
|
||||
|
||||
return self._truncate_output(output, tool=tool, task=task)
|
||||
|
||||
def _prepare_call_args(
|
||||
self,
|
||||
tool: ToolDefinition,
|
||||
arguments: dict[str, Any],
|
||||
*,
|
||||
task: Any = None,
|
||||
on_progress: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
call_args = dict(arguments)
|
||||
signature = inspect.signature(tool.func)
|
||||
for alias, canonical in _PARAM_ALIASES.items():
|
||||
if alias in call_args and alias not in signature.parameters and canonical in signature.parameters:
|
||||
call_args[canonical] = call_args.pop(alias)
|
||||
if "task" in signature.parameters and "task" not in call_args:
|
||||
call_args["task"] = task
|
||||
if "on_progress" in signature.parameters and "on_progress" not in call_args:
|
||||
call_args["on_progress"] = on_progress
|
||||
# Reject unknown arguments with a helpful error instead of silently
|
||||
# dropping them. The error is caught by `invoke()` and packaged as
|
||||
# `{"success": False, "error": ...}`, which the agent's tool-call
|
||||
# loop feeds back into the model so it can retry with the right
|
||||
# parameter names. Silent dropping would hide data loss when a
|
||||
# tool signature is changed without updating agent prompts.
|
||||
has_var_keyword = any(
|
||||
p.kind == inspect.Parameter.VAR_KEYWORD
|
||||
for p in signature.parameters.values()
|
||||
)
|
||||
if not has_var_keyword:
|
||||
valid_params = [
|
||||
name for name in signature.parameters
|
||||
if name not in {"task", "on_progress"}
|
||||
]
|
||||
extra = sorted(set(call_args) - set(signature.parameters))
|
||||
if extra:
|
||||
raise ValueError(
|
||||
f"Tool `{tool.name}` received unknown argument(s): "
|
||||
f"{', '.join(repr(key) for key in extra)}. "
|
||||
f"Valid arguments: {', '.join(repr(p) for p in valid_params)}. "
|
||||
"Please retry with a supported argument name."
|
||||
)
|
||||
return call_args
|
||||
|
||||
@staticmethod
|
||||
def _truncate_output(
|
||||
output: dict[str, Any],
|
||||
*,
|
||||
tool: ToolDefinition,
|
||||
task: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a recoverable output budget when serialized output is large."""
|
||||
return budget_tool_output(
|
||||
output,
|
||||
tool_name=tool.name,
|
||||
task=task,
|
||||
max_chars=int(tool.max_result_chars or _OUTPUT_LIMIT),
|
||||
preview_chars=tool.preview_chars,
|
||||
persist_large_results=bool(tool.persist_large_results),
|
||||
self_bounded_output=bool(tool.self_bounded_output),
|
||||
)
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Structured shell execution tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from opc.layer4_tools.execution_context import (
|
||||
build_subprocess_env,
|
||||
resolve_task_execution_context,
|
||||
wrap_command_for_context,
|
||||
)
|
||||
from opc.layer4_tools.registry import ToolDefinition
|
||||
from opc.layer2_organization.work_item_identity import work_item_turn_type_from_metadata
|
||||
|
||||
|
||||
_STDOUT_LIMIT = 50_000
|
||||
_STDERR_LIMIT = 20_000
|
||||
_SETUP_STAGE_DEFAULT_TIMEOUT = 1800
|
||||
_DEFAULT_SHELL_TIMEOUT = 300
|
||||
_POWERSHELL_CMD_SEPARATOR = " ; "
|
||||
_BASH_CMD_SEPARATOR = " && "
|
||||
_STREAM_READ_SIZE = 8192
|
||||
|
||||
|
||||
def _resolve_working_directory(
|
||||
working_directory: str | None = None,
|
||||
task: Any | None = None,
|
||||
) -> str:
|
||||
cwd = str(working_directory or "").strip()
|
||||
if not cwd and task is not None:
|
||||
metadata = getattr(task, "metadata", {}) or {}
|
||||
execution_context = dict(metadata.get("_execution_context", {}) or {})
|
||||
candidates = [
|
||||
str(execution_context.get("workspace_root", "") or "").strip(),
|
||||
str(execution_context.get("output_root", "") or "").strip(),
|
||||
str(metadata.get("workspace_root", "") or "").strip(),
|
||||
str(metadata.get("comms_workspace_root", "") or "").strip(),
|
||||
str(metadata.get("output_root", "") or "").strip(),
|
||||
str(metadata.get("target_output_dir", "") or "").strip(),
|
||||
]
|
||||
fallback = ""
|
||||
for raw in candidates:
|
||||
if not raw:
|
||||
continue
|
||||
path = Path(raw).expanduser()
|
||||
if path.exists() and path.is_dir():
|
||||
return str(path)
|
||||
if not fallback:
|
||||
fallback = str(path)
|
||||
cwd = fallback
|
||||
return cwd or os.getcwd()
|
||||
|
||||
|
||||
def _shell_binary(preferred: str, fallback: str) -> str:
|
||||
return shutil.which(preferred) or fallback
|
||||
|
||||
|
||||
async def _run_shell_command(
|
||||
*,
|
||||
shell_name: str,
|
||||
command: str,
|
||||
args: list[str],
|
||||
working_directory: str | None = None,
|
||||
timeout: int = _DEFAULT_SHELL_TIMEOUT,
|
||||
task: Any | None = None,
|
||||
on_progress: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
cwd = _resolve_working_directory(working_directory, task)
|
||||
cwd_path = Path(cwd).expanduser()
|
||||
resolved_cwd = str(cwd_path.resolve(strict=False))
|
||||
if not cwd_path.exists() or not cwd_path.is_dir():
|
||||
return {
|
||||
"success": False,
|
||||
"shell": shell_name,
|
||||
"command": command,
|
||||
"cwd": resolved_cwd,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"exit_code": -1,
|
||||
"timed_out": False,
|
||||
"error": f"Working directory does not exist: {resolved_cwd}",
|
||||
"sandbox": {
|
||||
"platform": "",
|
||||
"requested_mode": "",
|
||||
"effective_mode": "off",
|
||||
"available": False,
|
||||
"fallback_used": False,
|
||||
},
|
||||
"execution_context": {},
|
||||
}
|
||||
if task is not None:
|
||||
meta = getattr(task, "metadata", {}) or {}
|
||||
override = meta.get("shell_timeout_override")
|
||||
if override is not None:
|
||||
try:
|
||||
timeout = max(int(override), timeout)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif work_item_turn_type_from_metadata(meta, fallback="") == "setup":
|
||||
timeout = max(timeout, _SETUP_STAGE_DEFAULT_TIMEOUT)
|
||||
shell_prefix = ""
|
||||
shell_prefix_win = ""
|
||||
inherited = meta.get("inherited_environment")
|
||||
if isinstance(inherited, dict):
|
||||
shell_prefix = str(inherited.get("shell_prefix", "") or "").strip()
|
||||
shell_prefix_win = str(inherited.get("shell_prefix_win", "") or "").strip()
|
||||
if not shell_prefix:
|
||||
manifest = meta.get("environment_manifest")
|
||||
if isinstance(manifest, dict):
|
||||
shell_prefix = str(manifest.get("shell_prefix", "") or "").strip()
|
||||
shell_prefix_win = str(manifest.get("shell_prefix_win", "") or "").strip()
|
||||
is_powershell = shell_name == "powershell"
|
||||
active_prefix = shell_prefix_win if (is_powershell and shell_prefix_win) else shell_prefix
|
||||
if active_prefix and active_prefix not in command:
|
||||
separator = _POWERSHELL_CMD_SEPARATOR if is_powershell else _BASH_CMD_SEPARATOR
|
||||
command = f"{active_prefix}{separator}{command}"
|
||||
args = [args[0], args[1], command] if len(args) >= 2 else args
|
||||
context = resolve_task_execution_context(task)
|
||||
if resolved_cwd and not context.get("workspace_root"):
|
||||
context["workspace_root"] = resolved_cwd
|
||||
env = build_subprocess_env(context)
|
||||
try:
|
||||
wrapped_args, sandbox_meta = wrap_command_for_context(args, cwd=resolved_cwd, context=context)
|
||||
except RuntimeError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"shell": shell_name,
|
||||
"command": command,
|
||||
"cwd": resolved_cwd,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"exit_code": -1,
|
||||
"timed_out": False,
|
||||
"error": str(exc),
|
||||
"sandbox": {
|
||||
"platform": (context.get("sandbox", {}) or {}).get("platform", ""),
|
||||
"requested_mode": (context.get("sandbox", {}) or {}).get("mode", ""),
|
||||
"effective_mode": "off",
|
||||
"available": False,
|
||||
"fallback_used": False,
|
||||
},
|
||||
"execution_context": _context_preview(context),
|
||||
}
|
||||
proc: asyncio.subprocess.Process | None = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*wrapped_args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=resolved_cwd,
|
||||
env=env,
|
||||
)
|
||||
stdout_chunks: list[str] = []
|
||||
stderr_chunks: list[str] = []
|
||||
|
||||
async def _pump(stream: Any, bucket: list[str], stream_name: str, limit: int) -> None:
|
||||
async for chunk in _iter_stream_lines(stream):
|
||||
text = chunk.decode("utf-8", errors="replace")
|
||||
bucket.append(text)
|
||||
joined = "".join(bucket)
|
||||
if len(joined) > limit:
|
||||
bucket[:] = [joined[:limit]]
|
||||
if on_progress:
|
||||
try:
|
||||
await on_progress(text.rstrip("\r\n"), stream=stream_name)
|
||||
except TypeError:
|
||||
await on_progress(text.rstrip("\r\n"))
|
||||
|
||||
stdout_task = asyncio.create_task(_pump(proc.stdout, stdout_chunks, "stdout", _STDOUT_LIMIT))
|
||||
stderr_task = asyncio.create_task(_pump(proc.stderr, stderr_chunks, "stderr", _STDERR_LIMIT))
|
||||
await asyncio.wait_for(proc.wait(), timeout=timeout)
|
||||
await asyncio.gather(stdout_task, stderr_task)
|
||||
stdout = "".join(stdout_chunks)[:_STDOUT_LIMIT]
|
||||
stderr = "".join(stderr_chunks)[:_STDERR_LIMIT]
|
||||
return {
|
||||
"success": proc.returncode == 0,
|
||||
"shell": shell_name,
|
||||
"command": command,
|
||||
"cwd": resolved_cwd,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"exit_code": proc.returncode,
|
||||
"timed_out": False,
|
||||
"sandbox": sandbox_meta,
|
||||
"execution_context": _context_preview(context),
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
if proc is not None:
|
||||
proc.kill()
|
||||
return {
|
||||
"success": False,
|
||||
"shell": shell_name,
|
||||
"command": command,
|
||||
"cwd": resolved_cwd,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"exit_code": -1,
|
||||
"timed_out": True,
|
||||
"error": f"Command timed out after {timeout}s",
|
||||
"sandbox": sandbox_meta,
|
||||
"execution_context": _context_preview(context),
|
||||
}
|
||||
|
||||
|
||||
async def _iter_stream_lines(stream: asyncio.StreamReader) -> AsyncIterator[bytes]:
|
||||
buffer = bytearray()
|
||||
while True:
|
||||
chunk = await stream.read(_STREAM_READ_SIZE)
|
||||
if not chunk:
|
||||
if buffer:
|
||||
yield bytes(buffer)
|
||||
return
|
||||
buffer.extend(chunk)
|
||||
while True:
|
||||
newline_index = buffer.find(b"\n")
|
||||
if newline_index < 0:
|
||||
break
|
||||
line = bytes(buffer[: newline_index + 1])
|
||||
del buffer[: newline_index + 1]
|
||||
yield line
|
||||
|
||||
|
||||
def _context_preview(context: dict[str, Any]) -> dict[str, Any]:
|
||||
sandbox = dict(context.get("sandbox", {}) or {})
|
||||
return {
|
||||
"workspace_root": str(context.get("workspace_root", "") or ""),
|
||||
"output_root": str(context.get("output_root", "") or ""),
|
||||
"comms_root": str(context.get("comms_root", "") or ""),
|
||||
"venv_path": str(context.get("venv_path", "") or ""),
|
||||
"python_executable": str(context.get("python_executable", "") or ""),
|
||||
"venv_provider": str(context.get("venv_provider", "") or ""),
|
||||
"preparation_error": str(context.get("preparation_error", "") or ""),
|
||||
"sandbox": sandbox,
|
||||
}
|
||||
|
||||
|
||||
async def bash_exec(
|
||||
command: str,
|
||||
working_directory: str | None = None,
|
||||
timeout: int = _DEFAULT_SHELL_TIMEOUT,
|
||||
task: Any | None = None,
|
||||
on_progress: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a command using bash/sh semantics."""
|
||||
shell_binary = _shell_binary("bash", "sh" if os.name != "nt" else "bash")
|
||||
return await _run_shell_command(
|
||||
shell_name="bash",
|
||||
command=command,
|
||||
args=[shell_binary, "-lc", command],
|
||||
working_directory=working_directory,
|
||||
timeout=timeout,
|
||||
task=task,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
|
||||
async def powershell_exec(
|
||||
command: str,
|
||||
working_directory: str | None = None,
|
||||
timeout: int = _DEFAULT_SHELL_TIMEOUT,
|
||||
task: Any | None = None,
|
||||
on_progress: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a command using PowerShell semantics."""
|
||||
executable = shutil.which("pwsh") or shutil.which("powershell") or "powershell"
|
||||
return await _run_shell_command(
|
||||
shell_name="powershell",
|
||||
command=command,
|
||||
args=[executable, "-NoProfile", "-Command", command],
|
||||
working_directory=working_directory,
|
||||
timeout=timeout,
|
||||
task=task,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
|
||||
async def shell_exec(
|
||||
command: str,
|
||||
working_directory: str | None = None,
|
||||
timeout: int = _DEFAULT_SHELL_TIMEOUT,
|
||||
shell: str | None = None,
|
||||
task: Any | None = None,
|
||||
on_progress: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Compatibility wrapper that selects bash or PowerShell."""
|
||||
normalized = str(shell or "").strip().lower()
|
||||
if normalized == "powershell":
|
||||
return await powershell_exec(
|
||||
command=command,
|
||||
working_directory=working_directory,
|
||||
timeout=timeout,
|
||||
task=task,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
if os.name == "nt" and normalized not in {"bash", "sh"}:
|
||||
return await powershell_exec(
|
||||
command=command,
|
||||
working_directory=working_directory,
|
||||
timeout=timeout,
|
||||
task=task,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
return await bash_exec(
|
||||
command=command,
|
||||
working_directory=working_directory,
|
||||
timeout=timeout,
|
||||
task=task,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
|
||||
def _shell_schema(description: str) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "string", "description": description},
|
||||
"working_directory": {"type": "string", "description": "Working directory for the command (optional)"},
|
||||
"timeout": {"type": "integer", "description": "Timeout in seconds", "default": _DEFAULT_SHELL_TIMEOUT},
|
||||
},
|
||||
"required": ["command"],
|
||||
}
|
||||
|
||||
|
||||
def create_shell_tools() -> list[ToolDefinition]:
|
||||
return [
|
||||
ToolDefinition(
|
||||
name="shell_exec",
|
||||
description="Execute a shell command. Selects bash or PowerShell based on platform or the optional `shell` hint.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
**_shell_schema("The shell command to execute")["properties"],
|
||||
"shell": {
|
||||
"type": "string",
|
||||
"description": "Optional shell hint: bash | powershell",
|
||||
"default": "",
|
||||
},
|
||||
},
|
||||
"required": ["command"],
|
||||
},
|
||||
func=shell_exec,
|
||||
category="compute",
|
||||
requires_confirmation=True,
|
||||
concurrency_safe=False,
|
||||
read_only=False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def create_shell_tool() -> ToolDefinition:
|
||||
"""Backward-compatible helper used by older callers."""
|
||||
for tool in create_shell_tools():
|
||||
if tool.name == "shell_exec":
|
||||
return tool
|
||||
raise RuntimeError("shell_exec definition is missing")
|
||||
@@ -0,0 +1,83 @@
|
||||
"""TODO tool — structured task tracking for agents.
|
||||
|
||||
Schema definitions only. Execution is intercepted by NativeRuntimeV2, which
|
||||
persists the task ledger in runtime session state for resume and verification.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from opc.layer4_tools.registry import ToolDefinition
|
||||
|
||||
|
||||
async def _todo_noop(**kwargs): # type: ignore[no-untyped-def]
|
||||
"""Placeholder — never called; NativeRuntimeV2 intercepts todo_* calls."""
|
||||
return {"error": "todo tool must be intercepted by NativeRuntimeV2", "success": False}
|
||||
|
||||
|
||||
def create_todo_tools() -> list[ToolDefinition]:
|
||||
"""Return the two TODO tool definitions (todo_write, todo_read)."""
|
||||
todo_write = ToolDefinition(
|
||||
name="todo_write",
|
||||
description=(
|
||||
"Create or update a structured TODO list for tracking multi-step tasks. "
|
||||
"Pass either a JSON string or a real array of items. "
|
||||
"Preferred OpenOPC task-ledger fields are 'content', 'active_form', and "
|
||||
"'status' ('pending' | 'in_progress' | 'completed'). "
|
||||
"Fields 'id'/'title'/'status' are also accepted. "
|
||||
"Keep only ONE item 'in_progress' at a time and add new items dynamically. "
|
||||
"The runtime persists this ledger across pause/resume and verification."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"todos": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "JSON array of todo items.",
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
"active_form": {"type": "string"},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["pending", "in_progress", "completed", "done"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"description": "TODO items, e.g. "
|
||||
'[{"content":"Inspect runtime_v2","active_form":"Inspecting runtime_v2","status":"in_progress"}]',
|
||||
},
|
||||
},
|
||||
"required": ["todos"],
|
||||
},
|
||||
func=_todo_noop,
|
||||
category="planning",
|
||||
concurrency_safe=False,
|
||||
read_only=False,
|
||||
runtime_managed=True,
|
||||
)
|
||||
|
||||
todo_read = ToolDefinition(
|
||||
name="todo_read",
|
||||
description="Read the current runtime task ledger snapshot. Returns all items with their statuses.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
func=_todo_noop,
|
||||
category="planning",
|
||||
concurrency_safe=True,
|
||||
read_only=True,
|
||||
runtime_managed=True,
|
||||
)
|
||||
|
||||
return [todo_write, todo_read]
|
||||
@@ -0,0 +1,258 @@
|
||||
"""User input request tool for structured pause/resume."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from opc.layer4_tools.registry import ToolDefinition
|
||||
|
||||
|
||||
_OPTION_IDS: tuple[str, ...] = ("a", "b", "c")
|
||||
|
||||
|
||||
def _clean_text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _as_list(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, tuple):
|
||||
return list(value)
|
||||
return [value]
|
||||
|
||||
|
||||
def _bool_or_default(value: Any, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
text = _clean_text(value).lower()
|
||||
if text in {"true", "1", "yes", "y", "on"}:
|
||||
return True
|
||||
if text in {"false", "0", "no", "n", "off"}:
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _unique_id(candidate: str, used: set[str], fallback: str) -> str:
|
||||
base = _clean_text(candidate) or fallback
|
||||
if base not in used:
|
||||
used.add(base)
|
||||
return base
|
||||
index = 2
|
||||
while f"{base}_{index}" in used:
|
||||
index += 1
|
||||
resolved = f"{base}_{index}"
|
||||
used.add(resolved)
|
||||
return resolved
|
||||
|
||||
|
||||
def _normalize_options(raw_options: Any) -> list[dict[str, str]]:
|
||||
options: list[dict[str, str]] = []
|
||||
used_ids: set[str] = set()
|
||||
for index, raw_option in enumerate(_as_list(raw_options)[:3]):
|
||||
default_id = _OPTION_IDS[index]
|
||||
if isinstance(raw_option, str):
|
||||
label = _clean_text(raw_option)
|
||||
option_id = default_id
|
||||
description = ""
|
||||
elif isinstance(raw_option, Mapping):
|
||||
label = _clean_text(
|
||||
raw_option.get("label")
|
||||
or raw_option.get("title")
|
||||
or raw_option.get("value")
|
||||
or raw_option.get("id")
|
||||
)
|
||||
option_id = _clean_text(raw_option.get("id")) or default_id
|
||||
description = _clean_text(raw_option.get("description"))
|
||||
else:
|
||||
label = _clean_text(raw_option)
|
||||
option_id = default_id
|
||||
description = ""
|
||||
if not label:
|
||||
continue
|
||||
option_id = _unique_id(option_id, used_ids, default_id)
|
||||
option: dict[str, str] = {"id": option_id, "label": label}
|
||||
if description:
|
||||
option["description"] = description
|
||||
options.append(option)
|
||||
return options
|
||||
|
||||
|
||||
def normalize_user_input_questions(questions: Any) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""Normalize legacy and structured user-input questions.
|
||||
|
||||
The returned tuple is ``(input_questions, legacy_question_texts)``. The
|
||||
legacy texts keep older checkpoint consumers working, while
|
||||
``input_questions`` drives the newer choice/freeform UI.
|
||||
"""
|
||||
normalized: list[dict[str, Any]] = []
|
||||
legacy_texts: list[str] = []
|
||||
used_question_ids: set[str] = set()
|
||||
for index, raw_question in enumerate(_as_list(questions)):
|
||||
fallback_id = f"question_{index + 1}"
|
||||
if isinstance(raw_question, str):
|
||||
question_text = _clean_text(raw_question)
|
||||
if not question_text:
|
||||
continue
|
||||
question_id = _unique_id("", used_question_ids, fallback_id)
|
||||
question = {
|
||||
"id": question_id,
|
||||
"header": "",
|
||||
"question": question_text,
|
||||
"options": [],
|
||||
"allow_freeform": True,
|
||||
"required": True,
|
||||
}
|
||||
elif isinstance(raw_question, Mapping):
|
||||
question_text = _clean_text(
|
||||
raw_question.get("question")
|
||||
or raw_question.get("prompt")
|
||||
or raw_question.get("body")
|
||||
or raw_question.get("text")
|
||||
)
|
||||
header = _clean_text(raw_question.get("header") or raw_question.get("title"))
|
||||
if not question_text and header:
|
||||
question_text = header
|
||||
if not question_text:
|
||||
continue
|
||||
question_id = _unique_id(_clean_text(raw_question.get("id")), used_question_ids, fallback_id)
|
||||
question = {
|
||||
"id": question_id,
|
||||
"header": header,
|
||||
"question": question_text,
|
||||
"options": _normalize_options(raw_question.get("options") or raw_question.get("choices") or []),
|
||||
"allow_freeform": _bool_or_default(raw_question.get("allow_freeform"), True),
|
||||
"required": _bool_or_default(raw_question.get("required"), True),
|
||||
}
|
||||
else:
|
||||
question_text = _clean_text(raw_question)
|
||||
if not question_text:
|
||||
continue
|
||||
question_id = _unique_id("", used_question_ids, fallback_id)
|
||||
question = {
|
||||
"id": question_id,
|
||||
"header": "",
|
||||
"question": question_text,
|
||||
"options": [],
|
||||
"allow_freeform": True,
|
||||
"required": True,
|
||||
}
|
||||
normalized.append(question)
|
||||
legacy_texts.append(str(question.get("question", "")).strip())
|
||||
return normalized, legacy_texts
|
||||
|
||||
|
||||
def normalize_user_input_request(
|
||||
*,
|
||||
reason: str,
|
||||
questions: Any = None,
|
||||
required_fields: Any = None,
|
||||
context_note: str = "",
|
||||
) -> dict[str, Any]:
|
||||
input_questions, legacy_questions = normalize_user_input_questions(questions)
|
||||
normalized_required_fields = [
|
||||
field
|
||||
for field in (_clean_text(item) for item in _as_list(required_fields))
|
||||
if field
|
||||
]
|
||||
return {
|
||||
"requires_user_input": True,
|
||||
"reason": _clean_text(reason),
|
||||
"questions": legacy_questions,
|
||||
"input_questions": input_questions,
|
||||
"required_fields": normalized_required_fields,
|
||||
"context_note": _clean_text(context_note),
|
||||
"resume_hint": "Choose an option or provide the missing details, and OpenOPC will continue the task.",
|
||||
}
|
||||
|
||||
|
||||
async def request_user_input(
|
||||
reason: str,
|
||||
questions: list[Any] | None = None,
|
||||
required_fields: list[str] | None = None,
|
||||
context_note: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Return a structured user-input request that pauses execution."""
|
||||
return normalize_user_input_request(
|
||||
reason=reason,
|
||||
questions=questions,
|
||||
required_fields=required_fields,
|
||||
context_note=context_note,
|
||||
)
|
||||
|
||||
|
||||
def create_user_input_tool() -> ToolDefinition:
|
||||
return ToolDefinition(
|
||||
name="request_user_input",
|
||||
description=(
|
||||
"Pause execution and request missing information from the user. "
|
||||
"Use this only when the latest user reply still leaves a specific blocking gap. "
|
||||
"If you follow up, ask only for that gap and do not repeat the same broad question."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Brief explanation of the specific missing information that blocks execution",
|
||||
},
|
||||
"questions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"header": {"type": "string"},
|
||||
"question": {"type": "string"},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"maxItems": 3,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"label": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
"required": ["label"],
|
||||
},
|
||||
},
|
||||
"allow_freeform": {"type": "boolean", "default": True},
|
||||
"required": {"type": "boolean", "default": True},
|
||||
},
|
||||
"required": ["question"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"description": (
|
||||
"Specific questions the user should answer. Each structured question can have up to "
|
||||
"three selectable options; freeform Other is allowed by default."
|
||||
),
|
||||
"default": [],
|
||||
},
|
||||
"required_fields": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Required field names still missing after considering the latest user reply",
|
||||
"default": [],
|
||||
},
|
||||
"context_note": {
|
||||
"type": "string",
|
||||
"description": "Optional note stating what is already understood and what remains unresolved",
|
||||
"default": "",
|
||||
},
|
||||
},
|
||||
"required": ["reason"],
|
||||
},
|
||||
func=request_user_input,
|
||||
category="interaction",
|
||||
requires_confirmation=False,
|
||||
)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Web search and fetch tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from opc.layer4_tools.output_budget import clip_text, persist_tool_result
|
||||
from opc.layer4_tools.registry import ToolDefinition
|
||||
|
||||
|
||||
class _ReadableHTMLParser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._parts: list[str] = []
|
||||
self._skip_depth = 0
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
_ = attrs
|
||||
if tag in {"script", "style", "noscript"}:
|
||||
self._skip_depth += 1
|
||||
if tag in {"p", "br", "div", "section", "article", "li", "tr", "h1", "h2", "h3", "h4"}:
|
||||
self._parts.append("\n")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag in {"script", "style", "noscript"} and self._skip_depth > 0:
|
||||
self._skip_depth -= 1
|
||||
if tag in {"p", "div", "section", "article", "li", "tr"}:
|
||||
self._parts.append("\n")
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._skip_depth:
|
||||
return
|
||||
text = " ".join(str(data or "").split())
|
||||
if text:
|
||||
self._parts.append(text + " ")
|
||||
|
||||
def text(self) -> str:
|
||||
lines = []
|
||||
for raw in "".join(self._parts).splitlines():
|
||||
line = " ".join(raw.split())
|
||||
if line:
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _html_to_text(value: str) -> str:
|
||||
parser = _ReadableHTMLParser()
|
||||
parser.feed(value)
|
||||
return parser.text() or value
|
||||
|
||||
|
||||
async def web_search(query: str, max_results: int = 5) -> dict[str, Any]:
|
||||
"""Search the web using DuckDuckGo HTML scraping (no API key needed)."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(
|
||||
"https://html.duckduckgo.com/html/",
|
||||
params={"q": query},
|
||||
headers={"User-Agent": "Mozilla/5.0 (compatible; OPC/1.0)"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
text = resp.text
|
||||
|
||||
results: list[dict[str, str]] = []
|
||||
import re
|
||||
links = re.findall(r'class="result__a"[^>]*href="([^"]*)"[^>]*>(.*?)</a>', text)
|
||||
snippets = re.findall(r'class="result__snippet"[^>]*>(.*?)</[^>]+>', text, re.DOTALL)
|
||||
for i, (url, title) in enumerate(links[:max_results]):
|
||||
snippet = snippets[i].strip() if i < len(snippets) else ""
|
||||
snippet = re.sub(r"<[^>]+>", "", snippet).strip()
|
||||
title = re.sub(r"<[^>]+>", "", title).strip()
|
||||
results.append({"title": title, "url": url, "snippet": snippet})
|
||||
|
||||
return {"results": results, "query": query}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "query": query}
|
||||
|
||||
|
||||
async def web_fetch(
|
||||
url: str,
|
||||
max_length: int = 20000,
|
||||
offset: int = 0,
|
||||
save_full: bool = True,
|
||||
task: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch a URL and return its text content."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers={"User-Agent": "Mozilla/5.0 (compatible; OPC/1.0)"})
|
||||
resp.raise_for_status()
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
if "text" in content_type or "json" in content_type or "xml" in content_type:
|
||||
text = _html_to_text(resp.text) if "html" in content_type else resp.text
|
||||
start = max(0, int(offset or 0))
|
||||
limit = max(1, int(max_length or 20000))
|
||||
sliced = text[start:]
|
||||
preview = clip_text(sliced, limit=limit, marker="web_fetch truncated")
|
||||
next_offset = start + preview.kept_chars if preview.truncated else None
|
||||
persisted = {}
|
||||
if save_full and (preview.truncated or start > 0):
|
||||
persisted = persist_tool_result(
|
||||
text,
|
||||
tool_name="web_fetch",
|
||||
task=task,
|
||||
extension="txt",
|
||||
)
|
||||
return {
|
||||
"content": preview.text,
|
||||
"url": str(resp.url),
|
||||
"final_url": str(resp.url),
|
||||
"status": resp.status_code,
|
||||
"content_type": content_type,
|
||||
"total_chars": len(text),
|
||||
"offset": start,
|
||||
"max_length": limit,
|
||||
"truncated": preview.truncated,
|
||||
"omitted_chars": preview.omitted_chars,
|
||||
"next_offset": next_offset,
|
||||
"full_content_path": persisted.get("full_output_path", ""),
|
||||
"success": True,
|
||||
}
|
||||
else:
|
||||
return {"error": f"Unsupported content type: {content_type}", "url": str(resp.url)}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "url": url}
|
||||
|
||||
|
||||
def create_web_tools() -> list[ToolDefinition]:
|
||||
return [
|
||||
ToolDefinition(
|
||||
name="web_search",
|
||||
description="Search the web for information. Returns titles, URLs, and snippets.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"max_results": {"type": "integer", "description": "Max results", "default": 5},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
func=web_search,
|
||||
category="search",
|
||||
),
|
||||
ToolDefinition(
|
||||
name="web_fetch",
|
||||
description="Fetch a URL and return its text content.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "URL to fetch"},
|
||||
"max_length": {"type": "integer", "description": "Max content length", "default": 20000},
|
||||
"offset": {"type": "integer", "description": "Character offset to start reading from", "default": 0},
|
||||
"save_full": {"type": "boolean", "description": "Persist full fetched text when preview is truncated", "default": True},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
func=web_fetch,
|
||||
category="search",
|
||||
self_bounded_output=True,
|
||||
max_result_chars=80_000,
|
||||
),
|
||||
]
|
||||
Reference in New Issue
Block a user