新增本地 public 工具与适配器

Made-with: Cursor
This commit is contained in:
oliver 2026-04-30 23:18:58 +08:00
parent 4d9232f3b3
commit d8e2f4cacc
11 changed files with 582 additions and 2 deletions

View file

@ -0,0 +1,27 @@
# Local Public Tools
This project exposes local atomic capabilities as shared `public` tools for all agents.
## Included P0 tools
- `local_run_command`
- `local_read_file`
- `local_write_file`
- `local_edit_file`
## Loading path
- Files live under `runtime/tools/public/`.
- They are auto-discovered by `runtime/tools/public_registry.py` (`*_tool` factory naming).
- Final exposure is controlled in `runtime/tools/catalog.py`.
## Risk gating
- `local_read_file` is `risk_level=low` and visible by default.
- `local_run_command`, `local_write_file`, `local_edit_file` are `risk_level=high`.
- High-risk public tools are hidden unless `AIA_PUBLIC_TOOLS_ALLOW_HIGH=1`.
## Local backend adapter
- Adapter path: `runtime/tools/local_sdk/adapter.py`.
- Uses a self-implemented local backend (cross-platform) with a stable tool contract.

View file

@ -5,6 +5,7 @@ import os
import time import time
import uuid import uuid
import threading import threading
import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
@ -35,6 +36,9 @@ from oclaw.runtime.worker import ensure_worker_started
from oclaw.runtime.orchestration.trace import new_span_id, new_trace_id from oclaw.runtime.orchestration.trace import new_span_id, new_trace_id
from oclaw.runtime.chat.tool_runtime import compact_turn_tool_messages_for_storage from oclaw.runtime.chat.tool_runtime import compact_turn_tool_messages_for_storage
from oclaw.runtime.chat.model_path_audit import ensure_no_tool_or_embedded_image_payload from oclaw.runtime.chat.model_path_audit import ensure_no_tool_or_embedded_image_payload
from oclaw.runtime.tools.local_sdk import local_adapter_startup_self_check
logger = logging.getLogger(__name__)
_OC_STAGE_BY_EVENT: dict[str, str] = { _OC_STAGE_BY_EVENT: dict[str, str] = {
"gateway_received": "ingress", "gateway_received": "ingress",
@ -98,6 +102,16 @@ class GatewayDispatchPlan:
class OclawGateway: class OclawGateway:
def __init__(self, *, store: Any): def __init__(self, *, store: Any):
self.store = store self.store = store
try:
check = local_adapter_startup_self_check()
if not bool(check.get("ok")):
logger.warning(
"Local adapter startup self-check failed: %s (%s)",
str(check.get("error_code") or ""),
str(check.get("error") or ""),
)
except Exception:
pass
@staticmethod @staticmethod
def _looks_like_manager_instruction(reply: str, instruction: str) -> bool: def _looks_like_manager_instruction(reply: str, instruction: str) -> bool:

View file

@ -87,7 +87,7 @@ def skill_install_paths():
home / ".vibe/skills", home / ".vibe/skills",
home / ".mux/skills", home / ".mux/skills",
home / ".config/opencode/skills", home / ".config/opencode/skills",
home / ".openhands/skills", home / ".oh/skills",
home / ".pi/agent/skills", home / ".pi/agent/skills",
home / ".qoder/skills", home / ".qoder/skills",
home / ".qwen/skills", home / ".qwen/skills",

View file

@ -87,7 +87,7 @@ def skill_install_paths():
home / ".vibe/skills", home / ".vibe/skills",
home / ".mux/skills", home / ".mux/skills",
home / ".config/opencode/skills", home / ".config/opencode/skills",
home / ".openhands/skills", home / ".oh/skills",
home / ".pi/agent/skills", home / ".pi/agent/skills",
home / ".qoder/skills", home / ".qoder/skills",
home / ".qwen/skills", home / ".qwen/skills",

View file

@ -0,0 +1,5 @@
from __future__ import annotations
from .adapter import LocalAdapter, LocalAdapterError, get_local_adapter, local_adapter_startup_self_check
__all__ = ["LocalAdapter", "LocalAdapterError", "get_local_adapter", "local_adapter_startup_self_check"]

View file

@ -0,0 +1,197 @@
from __future__ import annotations
import os
import subprocess
from dataclasses import dataclass
from typing import Any
from oclaw.runtime.tools.experts.workspace.workspace_base import resolve_workspace_path, truncate_text
@dataclass(frozen=True)
class LocalAdapterError(Exception):
error_code: str
message: str
def as_result(self) -> dict[str, Any]:
return {"ok": False, "error_code": self.error_code, "error": self.message}
class LocalAdapter:
"""Self-implemented local backend (cross-platform)."""
def __init__(self) -> None:
self._init_error: LocalAdapterError | None = None
def run_command(self, *, command: str, cwd: str | None = None, timeout: int = 30) -> dict[str, Any]:
cmd = str(command or "").strip()
if not cmd:
return {"ok": False, "error_code": "command_required", "error": "command_required"}
try:
timeout_s = max(1, min(int(timeout or 30), 600))
workdir = str(resolve_workspace_path(cwd or "."))
run_kwargs: dict[str, Any] = {
"cwd": workdir,
"shell": True,
"capture_output": True,
"text": True,
"timeout": float(timeout_s),
}
if os.name == "nt":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0
run_kwargs["startupinfo"] = startupinfo
run_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
cp = subprocess.run(cmd, **run_kwargs)
stdout = str(cp.stdout or "")
stderr = str(cp.stderr or "")
combined = stdout + (("\n" + stderr) if stderr else "")
return {
"ok": int(cp.returncode) == 0,
"stdout": stdout,
"stderr": stderr,
"exit_code": int(cp.returncode),
"output": truncate_text(combined, limit=20000),
"cwd": workdir,
}
except subprocess.TimeoutExpired as exc:
partial = (str(exc.stdout or "") + ("\n" + str(exc.stderr or "") if exc.stderr else "")).strip()
return {
"ok": False,
"error_code": "command_timeout",
"error": "command_timeout",
"stdout": str(exc.stdout or ""),
"stderr": str(exc.stderr or ""),
"output": truncate_text(partial, limit=20000),
}
except Exception as exc:
return {"ok": False, "error_code": "local_execution_error", "error": f"{type(exc).__name__}: {exc}"}
def read_file(
self,
*,
path: str,
start_line: int | None = None,
end_line: int | None = None,
) -> dict[str, Any]:
target = str(path or "").strip()
if not target:
return {"ok": False, "error_code": "path_required", "error": "path_required"}
try:
p = resolve_workspace_path(target)
if not p.exists() or not p.is_file():
return {"ok": False, "error_code": "file_not_found", "error": "file_not_found", "path": str(p)}
lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
s = max(1, int(start_line or 1))
e = int(end_line or len(lines))
e = max(s, min(e, len(lines)))
content = "\n".join(lines[s - 1 : e])
return {
"ok": True,
"path": str(p),
"start_line": s,
"end_line": e,
"total_lines": len(lines),
"content": content,
}
except Exception as exc:
return {"ok": False, "error_code": "local_execution_error", "error": f"{type(exc).__name__}: {exc}"}
def write_file(self, *, path: str, content: str, mode: str = "overwrite") -> dict[str, Any]:
target = str(path or "").strip()
if not target:
return {"ok": False, "error_code": "path_required", "error": "path_required"}
write_mode = str(mode or "overwrite").strip().lower()
if write_mode not in {"overwrite", "append"}:
return {"ok": False, "error_code": "invalid_mode", "error": "invalid_mode"}
try:
p = resolve_workspace_path(target)
p.parent.mkdir(parents=True, exist_ok=True)
if write_mode == "append" and p.exists():
merged = p.read_text(encoding="utf-8", errors="replace") + str(content or "")
p.write_text(merged, encoding="utf-8")
else:
p.write_text(str(content or ""), encoding="utf-8")
return {"ok": True, "path": str(p), "bytes": int(p.stat().st_size), "mode": write_mode}
except Exception as exc:
return {"ok": False, "error_code": "local_execution_error", "error": f"{type(exc).__name__}: {exc}"}
def edit_file(
self,
*,
path: str,
search: str | None = None,
replace: str | None = None,
start_line: int | None = None,
end_line: int | None = None,
replacement: str | None = None,
) -> dict[str, Any]:
target = str(path or "").strip()
if not target:
return {"ok": False, "error_code": "path_required", "error": "path_required"}
try:
p = resolve_workspace_path(target)
if not p.exists() or not p.is_file():
return {"ok": False, "error_code": "file_not_found", "error": "file_not_found", "path": str(p)}
text = p.read_text(encoding="utf-8", errors="replace")
if search is not None:
needle = str(search)
if needle not in text:
return {"ok": False, "error_code": "search_not_found", "error": "search_not_found"}
new_text = text.replace(needle, str(replace or ""), 1)
p.write_text(new_text, encoding="utf-8")
return {"ok": True, "path": str(p), "mode": "search_replace"}
if start_line is not None and end_line is not None:
lines = text.splitlines()
s = max(1, int(start_line))
e = max(s, int(end_line))
if s > len(lines):
return {"ok": False, "error_code": "line_out_of_range", "error": "line_out_of_range"}
e = min(e, len(lines))
repl_lines = str(replacement or "").splitlines()
new_lines = lines[: s - 1] + repl_lines + lines[e:]
suffix = "\n" if text.endswith("\n") else ""
p.write_text("\n".join(new_lines) + suffix, encoding="utf-8")
return {"ok": True, "path": str(p), "mode": "line_replace", "start_line": s, "end_line": e}
return {"ok": False, "error_code": "invalid_edit_arguments", "error": "line_range_required"}
except Exception as exc:
return {"ok": False, "error_code": "local_execution_error", "error": f"{type(exc).__name__}: {exc}"}
_ADAPTER_SINGLETON: LocalAdapter | None = None
def get_local_adapter() -> LocalAdapter:
global _ADAPTER_SINGLETON
if _ADAPTER_SINGLETON is None:
_ADAPTER_SINGLETON = LocalAdapter()
return _ADAPTER_SINGLETON
def local_adapter_startup_self_check() -> dict[str, Any]:
"""Best-effort startup probe for local backend availability."""
enabled = str(os.getenv("AIA_LOCAL_ADAPTER_STARTUP_SELF_CHECK") or "1").strip().lower() in {
"1",
"true",
"yes",
"on",
}
if not enabled:
return {"ok": True, "enabled": False}
try:
adapter = LocalAdapter()
if adapter._init_error is not None:
return {"ok": False, "enabled": True, "error_code": adapter._init_error.error_code, "error": adapter._init_error.message}
return {"ok": True, "enabled": True}
except Exception as exc:
return {
"ok": False,
"enabled": True,
"error_code": "local_adapter_startup_self_check_failed",
"error": f"{type(exc).__name__}: {exc}",
}
__all__ = ["LocalAdapter", "LocalAdapterError", "get_local_adapter", "local_adapter_startup_self_check"]

View file

@ -0,0 +1,69 @@
from __future__ import annotations
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from oclaw.runtime.tools.local_sdk import get_local_adapter
def local_edit_file_tool() -> ToolSpec:
def _handler(args: dict[str, Any]) -> dict[str, Any]:
path = str(args.get("path") or "").strip()
if not path:
return {"ok": False, "error_code": "path_required", "error": "path_required"}
search = args.get("search")
replace = args.get("replace")
start_line = args.get("start_line")
end_line = args.get("end_line")
replacement = args.get("replacement")
has_search = search is not None
has_range = start_line is not None or end_line is not None or replacement is not None
if has_search and has_range:
return {
"ok": False,
"error_code": "invalid_edit_arguments",
"error": "choose search/replace or line-range replacement, not both",
}
if not has_search and not has_range:
return {
"ok": False,
"error_code": "invalid_edit_arguments",
"error": "missing edit arguments",
}
return get_local_adapter().edit_file(
path=path,
search=str(search) if has_search else None,
replace=str(replace) if replace is not None else None,
start_line=int(start_line) if start_line is not None else None,
end_line=int(end_line) if end_line is not None else None,
replacement=str(replacement) if replacement is not None else None,
)
return ToolSpec(
name="local_edit_file",
description="Edit partial file content via local backend.",
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "Target file path."},
"search": {"type": "string", "description": "Search text for single replace mode."},
"replace": {"type": "string", "description": "Replacement text for search mode."},
"start_line": {"type": "integer", "description": "Start line for line-range replace mode."},
"end_line": {"type": "integer", "description": "End line for line-range replace mode."},
"replacement": {"type": "string", "description": "Replacement content for line-range mode."},
},
"required": ["path"],
"additionalProperties": False,
},
handler=_handler,
tags=frozenset({"public", "local", "workspace", "write", "edit"}),
risk_level="high",
timeout_s=30.0,
read_only=False,
)
__all__ = ["local_edit_file_tool"]

View file

@ -0,0 +1,43 @@
from __future__ import annotations
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from oclaw.runtime.tools.local_sdk import get_local_adapter
def local_read_file_tool() -> ToolSpec:
def _handler(args: dict[str, Any]) -> dict[str, Any]:
path = str(args.get("path") or "").strip()
if not path:
return {"ok": False, "error_code": "path_required", "error": "path_required"}
start_line = args.get("start_line")
end_line = args.get("end_line")
return get_local_adapter().read_file(
path=path,
start_line=int(start_line) if start_line is not None else None,
end_line=int(end_line) if end_line is not None else None,
)
return ToolSpec(
name="local_read_file",
description="Read file content via local backend.",
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "Target file path."},
"start_line": {"type": "integer", "description": "Optional start line (1-indexed)."},
"end_line": {"type": "integer", "description": "Optional end line (1-indexed)."},
},
"required": ["path"],
"additionalProperties": False,
},
handler=_handler,
tags=frozenset({"public", "local", "workspace", "read"}),
risk_level="low",
timeout_s=20.0,
read_only=True,
)
__all__ = ["local_read_file_tool"]

View file

@ -0,0 +1,39 @@
from __future__ import annotations
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from oclaw.runtime.tools.local_sdk import get_local_adapter
def local_run_command_tool() -> ToolSpec:
def _handler(args: dict[str, Any]) -> dict[str, Any]:
command = str(args.get("command") or "").strip()
if not command:
return {"ok": False, "error_code": "command_required", "error": "command_required"}
cwd = str(args.get("cwd") or "").strip() or None
timeout = int(args.get("timeout") or 30)
return get_local_adapter().run_command(command=command, cwd=cwd, timeout=timeout)
return ToolSpec(
name="local_run_command",
description="Run a shell command via local backend.",
parameters={
"type": "object",
"properties": {
"command": {"type": "string", "description": "Shell command to execute."},
"cwd": {"type": "string", "description": "Optional working directory."},
"timeout": {"type": "integer", "description": "Timeout in seconds.", "default": 30},
},
"required": ["command"],
"additionalProperties": False,
},
handler=_handler,
tags=frozenset({"public", "local", "exec", "workspace"}),
risk_level="high",
timeout_s=90.0,
read_only=False,
)
__all__ = ["local_run_command_tool"]

View file

@ -0,0 +1,39 @@
from __future__ import annotations
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from oclaw.runtime.tools.local_sdk import get_local_adapter
def local_write_file_tool() -> ToolSpec:
def _handler(args: dict[str, Any]) -> dict[str, Any]:
path = str(args.get("path") or "").strip()
if not path:
return {"ok": False, "error_code": "path_required", "error": "path_required"}
content = str(args.get("content") or "")
mode = str(args.get("mode") or "overwrite").strip().lower()
return get_local_adapter().write_file(path=path, content=content, mode=mode)
return ToolSpec(
name="local_write_file",
description="Write or append file content via local backend.",
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "Target file path."},
"content": {"type": "string", "description": "Content to write."},
"mode": {"type": "string", "enum": ["overwrite", "append"], "default": "overwrite"},
},
"required": ["path", "content"],
"additionalProperties": False,
},
handler=_handler,
tags=frozenset({"public", "local", "workspace", "write"}),
risk_level="high",
timeout_s=30.0,
read_only=False,
)
__all__ = ["local_write_file_tool"]

View file

@ -0,0 +1,147 @@
from __future__ import annotations
import tempfile
from pathlib import Path
from oclaw.runtime.tools.catalog import default_registry
from oclaw.runtime.tools.local_sdk.adapter import LocalAdapter
from oclaw.runtime.tools.public.local_edit_file_tool import local_edit_file_tool
from oclaw.runtime.tools.public.local_read_file_tool import local_read_file_tool
from oclaw.runtime.tools.public.local_run_command_tool import local_run_command_tool
from oclaw.runtime.tools.public.local_write_file_tool import local_write_file_tool
from oclaw.runtime.tools.public_registry import clear_public_tool_cache
def test_local_public_read_tool_visible_by_default() -> None:
clear_public_tool_cache()
names = [t.name for t in default_registry(expert="network_ops+memory", specialist="ops").list()]
assert "local_read_file" in names
assert "local_run_command" not in names
assert "local_write_file" not in names
assert "local_edit_file" not in names
def test_local_public_high_risk_tools_visible_when_enabled(monkeypatch) -> None:
clear_public_tool_cache()
monkeypatch.setenv("AIA_PUBLIC_TOOLS_ALLOW_HIGH", "1")
names = [t.name for t in default_registry(expert="network_ops+memory", specialist="ops").list()]
assert "local_read_file" in names
assert "local_run_command" in names
assert "local_write_file" in names
assert "local_edit_file" in names
def test_local_adapter_backend_roundtrip(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("OPS_WORKSPACE_ROOT", str(tmp_path))
adapter = LocalAdapter()
out_w = adapter.write_file(path="a.txt", content="hello\nworld\n")
assert out_w.get("ok") is True
out = adapter.edit_file(path="a.txt", search="hello", replace="hi")
assert out.get("ok") is True
out2 = adapter.run_command(command="echo hi", timeout=10)
assert out2.get("ok") is True
assert "hi" in str(out2.get("stdout") or "").lower()
def test_local_run_command_tool_handler(monkeypatch) -> None:
class _Adapter:
def run_command(self, *, command: str, cwd: str | None = None, timeout: int = 30):
return {"ok": True, "command": command, "cwd": cwd, "timeout": timeout}
monkeypatch.setattr("oclaw.runtime.tools.public.local_run_command_tool.get_local_adapter", lambda: _Adapter())
spec = local_run_command_tool()
out = spec.handler({"command": "echo hi", "cwd": "repo", "timeout": 9})
assert out.get("ok") is True
assert out.get("command") == "echo hi"
assert out.get("cwd") == "repo"
assert out.get("timeout") == 9
def test_local_read_file_tool_handler(monkeypatch) -> None:
class _Adapter:
def read_file(self, *, path: str, start_line: int | None = None, end_line: int | None = None):
return {"ok": True, "path": path, "start_line": start_line, "end_line": end_line}
monkeypatch.setattr("oclaw.runtime.tools.public.local_read_file_tool.get_local_adapter", lambda: _Adapter())
spec = local_read_file_tool()
out = spec.handler({"path": "a.py", "start_line": 2, "end_line": 5})
assert out.get("ok") is True
assert out.get("path") == "a.py"
assert out.get("start_line") == 2
assert out.get("end_line") == 5
def test_local_write_file_tool_handler(monkeypatch) -> None:
class _Adapter:
def write_file(self, *, path: str, content: str, mode: str = "overwrite"):
return {"ok": True, "path": path, "content": content, "mode": mode}
monkeypatch.setattr("oclaw.runtime.tools.public.local_write_file_tool.get_local_adapter", lambda: _Adapter())
spec = local_write_file_tool()
out = spec.handler({"path": "a.py", "content": "x=1", "mode": "append"})
assert out.get("ok") is True
assert out.get("path") == "a.py"
assert out.get("content") == "x=1"
assert out.get("mode") == "append"
def test_local_edit_file_tool_handler(monkeypatch) -> None:
class _Adapter:
def edit_file(
self,
*,
path: str,
search: str | None = None,
replace: str | None = None,
start_line: int | None = None,
end_line: int | None = None,
replacement: str | None = None,
):
return {
"ok": True,
"path": path,
"search": search,
"replace": replace,
"start_line": start_line,
"end_line": end_line,
"replacement": replacement,
}
monkeypatch.setattr("oclaw.runtime.tools.public.local_edit_file_tool.get_local_adapter", lambda: _Adapter())
spec = local_edit_file_tool()
out = spec.handler({"path": "a.py", "search": "foo", "replace": "bar"})
assert out.get("ok") is True
assert out.get("path") == "a.py"
assert out.get("search") == "foo"
assert out.get("replace") == "bar"
def test_local_tool_integration_roundtrip(monkeypatch) -> None:
run_spec = local_run_command_tool()
read_spec = local_read_file_tool()
write_spec = local_write_file_tool()
edit_spec = local_edit_file_tool()
tmpdir = Path(tempfile.mkdtemp(prefix="local_it_"))
monkeypatch.setenv("OPS_WORKSPACE_ROOT", str(tmpdir))
target = tmpdir / "it_sample.txt"
out_write = write_spec.handler({"path": str(target), "content": "line1\nline2\n", "mode": "overwrite"})
assert out_write.get("ok") is True, out_write
out_read_before = read_spec.handler({"path": str(target), "start_line": 1, "end_line": 10})
assert out_read_before.get("ok") is True, out_read_before
assert "line2" in str(out_read_before.get("content") or "")
out_edit = edit_spec.handler({"path": str(target), "search": "line2", "replace": "line2_edited"})
assert out_edit.get("ok") is True, out_edit
out_read_after = read_spec.handler({"path": str(target), "start_line": 1, "end_line": 10})
assert out_read_after.get("ok") is True, out_read_after
assert "line2_edited" in str(out_read_after.get("content") or "")
out_run = run_spec.handler({"command": "python -c \"print(12345)\"", "cwd": str(tmpdir), "timeout": 20})
assert out_run.get("ok") is True, out_run
stdout = str(out_run.get("stdout") or "")
assert "12345" in stdout