Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+122
View File
@@ -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,
),
]
+727
View File
@@ -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
+599
View File
@@ -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
+545
View File
@@ -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)
+303
View File
@@ -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]
+765
View File
@@ -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,
),
]
+77
View File
@@ -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",
),
]
+188
View File
@@ -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,
}
+130
View File
@@ -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,
)
+227
View File
@@ -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),
)
+359
View File
@@ -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")
+83
View File
@@ -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]
+258
View File
@@ -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,
)
+165
View File
@@ -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,
),
]