pdo-agent 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pdo/__init__.py +21 -0
- pdo/agent/__init__.py +6 -0
- pdo/agent/core.py +275 -0
- pdo/agent/delegate.py +56 -0
- pdo/agent/executor.py +87 -0
- pdo/agent/memory.py +191 -0
- pdo/agent/messages.py +87 -0
- pdo/agent/planner.py +38 -0
- pdo/agent/reviewer.py +25 -0
- pdo/agent/router.py +37 -0
- pdo/api.py +65 -0
- pdo/banner.py +53 -0
- pdo/config.py +151 -0
- pdo/llm.py +211 -0
- pdo/logging_setup.py +34 -0
- pdo/main.py +961 -0
- pdo/mcp.py +264 -0
- pdo/prompts/system.md +46 -0
- pdo/providers.py +86 -0
- pdo/rag.py +191 -0
- pdo/serve.py +124 -0
- pdo/skills.py +59 -0
- pdo/theme.py +47 -0
- pdo/tools/__init__.py +6 -0
- pdo/tools/base.py +89 -0
- pdo/tools/code.py +48 -0
- pdo/tools/data.py +57 -0
- pdo/tools/edit.py +55 -0
- pdo/tools/filesystem.py +175 -0
- pdo/tools/git.py +44 -0
- pdo/tools/memory.py +70 -0
- pdo/tools/rag.py +60 -0
- pdo/tools/registry.py +203 -0
- pdo/tools/search.py +83 -0
- pdo/tools/shell.py +125 -0
- pdo/tools/web.py +163 -0
- pdo_agent-2.0.0.dist-info/METADATA +456 -0
- pdo_agent-2.0.0.dist-info/RECORD +42 -0
- pdo_agent-2.0.0.dist-info/WHEEL +5 -0
- pdo_agent-2.0.0.dist-info/entry_points.txt +2 -0
- pdo_agent-2.0.0.dist-info/licenses/LICENSE +21 -0
- pdo_agent-2.0.0.dist-info/top_level.txt +1 -0
pdo/serve.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Serve mode: expose the PDO agent over stdio JSON-RPC (MCP-compatible).
|
|
2
|
+
|
|
3
|
+
``pdo --serve`` turns PDO into an **MCP server**: any MCP client (Claude
|
|
4
|
+
Desktop, Claude Code, another PDO…) can connect over stdio and call the
|
|
5
|
+
``run_task`` tool, which runs a prompt through the full PDO agent — tools,
|
|
6
|
+
sub-agents, codebase search and all — and returns the final answer.
|
|
7
|
+
|
|
8
|
+
Protocol: newline-delimited JSON-RPC 2.0 with the MCP handshake
|
|
9
|
+
(``initialize`` → ``tools/list`` → ``tools/call``), i.e. the mirror image of
|
|
10
|
+
:mod:`pdo.mcp`, which is PDO acting as a *client*.
|
|
11
|
+
|
|
12
|
+
Because stdin/stdout carry the protocol, serve mode never prints to stdout and
|
|
13
|
+
auto-denies interactive confirmations (dangerous commands are simply refused).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Any, TextIO
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from .mcp import PROTOCOL_VERSION
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_RUN_TASK_SCHEMA = {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"properties": {
|
|
29
|
+
"task": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"description": "The complete, self-contained task or question for the agent.",
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"required": ["task"],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PDOServer:
|
|
39
|
+
"""A minimal MCP server wrapping one PDO agent."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, agent: Any) -> None:
|
|
42
|
+
self._agent = agent
|
|
43
|
+
|
|
44
|
+
# --- request handling ---------------------------------------------------- #
|
|
45
|
+
def handle(self, message: dict[str, Any]) -> dict[str, Any] | None:
|
|
46
|
+
"""Handle one JSON-RPC message; returns the response (None for notifications)."""
|
|
47
|
+
method = message.get("method", "")
|
|
48
|
+
msg_id = message.get("id")
|
|
49
|
+
|
|
50
|
+
if msg_id is None: # notification (e.g. notifications/initialized)
|
|
51
|
+
return None
|
|
52
|
+
if method == "initialize":
|
|
53
|
+
return self._result(
|
|
54
|
+
msg_id,
|
|
55
|
+
{
|
|
56
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
57
|
+
"capabilities": {"tools": {}},
|
|
58
|
+
"serverInfo": {"name": "pdo", "version": __version__},
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
if method == "tools/list":
|
|
62
|
+
return self._result(
|
|
63
|
+
msg_id,
|
|
64
|
+
{
|
|
65
|
+
"tools": [
|
|
66
|
+
{
|
|
67
|
+
"name": "run_task",
|
|
68
|
+
"description": (
|
|
69
|
+
"Run a task with the PDO agent: it reasons, uses its "
|
|
70
|
+
"tools (files, shell, web, git, codebase search, "
|
|
71
|
+
"sub-agents, connected MCP servers) and returns the "
|
|
72
|
+
"final answer."
|
|
73
|
+
),
|
|
74
|
+
"inputSchema": _RUN_TASK_SCHEMA,
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
if method == "tools/call":
|
|
80
|
+
return self._call_tool(msg_id, message.get("params") or {})
|
|
81
|
+
return self._error(msg_id, -32601, f"method not found: {method}")
|
|
82
|
+
|
|
83
|
+
def _call_tool(self, msg_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
84
|
+
name = params.get("name")
|
|
85
|
+
if name != "run_task":
|
|
86
|
+
return self._error(msg_id, -32602, f"unknown tool: {name}")
|
|
87
|
+
task = (params.get("arguments") or {}).get("task", "").strip()
|
|
88
|
+
if not task:
|
|
89
|
+
return self._error(msg_id, -32602, "missing required argument: task")
|
|
90
|
+
try:
|
|
91
|
+
answer = self._agent.run_turn(task)
|
|
92
|
+
is_error = False
|
|
93
|
+
except Exception as exc: # noqa: BLE001 — report, keep serving
|
|
94
|
+
logger.exception("run_task failed")
|
|
95
|
+
answer, is_error = f"Agent error: {exc}", True
|
|
96
|
+
return self._result(
|
|
97
|
+
msg_id,
|
|
98
|
+
{"content": [{"type": "text", "text": answer}], "isError": is_error},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# --- plumbing -------------------------------------------------------------- #
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _result(msg_id: Any, result: dict[str, Any]) -> dict[str, Any]:
|
|
104
|
+
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _error(msg_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
108
|
+
return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
|
|
109
|
+
|
|
110
|
+
def serve_forever(self, stdin: TextIO, stdout: TextIO) -> None:
|
|
111
|
+
"""Blocking loop: one JSON-RPC message per line until EOF."""
|
|
112
|
+
for line in stdin:
|
|
113
|
+
line = line.strip()
|
|
114
|
+
if not line:
|
|
115
|
+
continue
|
|
116
|
+
try:
|
|
117
|
+
message = json.loads(line)
|
|
118
|
+
except json.JSONDecodeError:
|
|
119
|
+
logger.warning("Ignoring non-JSON input line")
|
|
120
|
+
continue
|
|
121
|
+
response = self.handle(message)
|
|
122
|
+
if response is not None:
|
|
123
|
+
stdout.write(json.dumps(response) + "\n")
|
|
124
|
+
stdout.flush()
|
pdo/skills.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""User-defined skills: reusable prompt templates invoked as slash commands.
|
|
2
|
+
|
|
3
|
+
Each ``.md`` file in the skills directory becomes a slash command named after the
|
|
4
|
+
file (``review.md`` → ``/review``). The file body is a prompt template; an
|
|
5
|
+
optional first line ``description: ...`` or ``# Title`` sets the menu description.
|
|
6
|
+
Use ``{{args}}`` in the template to interpolate whatever the user types after the
|
|
7
|
+
command (otherwise their text is appended).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Skill:
|
|
20
|
+
name: str # slash command name without the leading "/"
|
|
21
|
+
description: str
|
|
22
|
+
template: str
|
|
23
|
+
|
|
24
|
+
def render(self, args: str) -> str:
|
|
25
|
+
"""Produce the prompt to send, substituting the user's arguments."""
|
|
26
|
+
if "{{args}}" in self.template:
|
|
27
|
+
return self.template.replace("{{args}}", args)
|
|
28
|
+
return self.template if not args else f"{self.template}\n\n{args}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_skills(skills_dir: Path) -> dict[str, Skill]:
|
|
32
|
+
"""Load every ``*.md`` skill file from ``skills_dir`` (name → Skill)."""
|
|
33
|
+
skills: dict[str, Skill] = {}
|
|
34
|
+
if not skills_dir.exists():
|
|
35
|
+
return skills
|
|
36
|
+
for path in sorted(skills_dir.glob("*.md")):
|
|
37
|
+
try:
|
|
38
|
+
text = path.read_text(encoding="utf-8")
|
|
39
|
+
except OSError:
|
|
40
|
+
logger.warning("Could not read skill %s", path)
|
|
41
|
+
continue
|
|
42
|
+
name = path.stem.lower().lstrip("/")
|
|
43
|
+
description, template = _parse_skill(text, name)
|
|
44
|
+
skills[name] = Skill(name=name, description=description, template=template)
|
|
45
|
+
return skills
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_skill(text: str, name: str) -> tuple[str, str]:
|
|
49
|
+
lines = text.splitlines()
|
|
50
|
+
description = f"Custom skill: {name}"
|
|
51
|
+
if lines:
|
|
52
|
+
first = lines[0].strip()
|
|
53
|
+
if first.lower().startswith("description:"):
|
|
54
|
+
description = first.split(":", 1)[1].strip() or description
|
|
55
|
+
return description, "\n".join(lines[1:]).strip()
|
|
56
|
+
if first.startswith("# "):
|
|
57
|
+
description = first[2:].strip() or description
|
|
58
|
+
return description, "\n".join(lines[1:]).strip()
|
|
59
|
+
return description, text.strip()
|
pdo/theme.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Color themes for the terminal UI.
|
|
2
|
+
|
|
3
|
+
A theme is just an accent color used across the splash, input box, prompt, tool
|
|
4
|
+
bullets and footer. The accent is exposed both as a ``rich`` color name and as a
|
|
5
|
+
``prompt_toolkit`` ANSI name, since the two libraries name colors differently.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
# name -> (rich color, prompt_toolkit ansi color)
|
|
10
|
+
THEMES: dict[str, tuple[str, str]] = {
|
|
11
|
+
"cyan": ("cyan", "ansicyan"),
|
|
12
|
+
"green": ("green", "ansigreen"),
|
|
13
|
+
"magenta": ("magenta", "ansimagenta"),
|
|
14
|
+
"amber": ("yellow", "ansiyellow"),
|
|
15
|
+
"blue": ("blue", "ansiblue"),
|
|
16
|
+
"mono": ("white", "ansiwhite"),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
_DEFAULT = "cyan"
|
|
20
|
+
_current = _DEFAULT
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def set_theme(name: str) -> bool:
|
|
24
|
+
"""Switch the active theme. Returns False if the name is unknown."""
|
|
25
|
+
global _current
|
|
26
|
+
if name in THEMES:
|
|
27
|
+
_current = name
|
|
28
|
+
return True
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def current_theme() -> str:
|
|
33
|
+
return _current
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def theme_names() -> list[str]:
|
|
37
|
+
return list(THEMES)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def accent() -> str:
|
|
41
|
+
"""The accent color as a rich color name."""
|
|
42
|
+
return THEMES.get(_current, THEMES[_DEFAULT])[0]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def accent_ansi() -> str:
|
|
46
|
+
"""The accent color as a prompt_toolkit ANSI color name."""
|
|
47
|
+
return THEMES.get(_current, THEMES[_DEFAULT])[1]
|
pdo/tools/__init__.py
ADDED
pdo/tools/base.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""The ``Tool`` base class and shared confirmation helper.
|
|
2
|
+
|
|
3
|
+
Every tool subclasses :class:`Tool`, declares a JSON parameter schema, and is
|
|
4
|
+
executed by the agent through the registry. The agent never imports a concrete
|
|
5
|
+
tool, which is what keeps new tools a pure add-on.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Process-wide confirmation override. Non-interactive modes (e.g. `pdo --serve`,
|
|
21
|
+
# where stdin/stdout carry JSON-RPC) install a handler here so confirmation can
|
|
22
|
+
# never block on, or write to, the protocol streams.
|
|
23
|
+
_confirm_override: Any = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def set_confirm_override(handler) -> None:
|
|
27
|
+
"""Replace interactive confirmation globally (None restores the default)."""
|
|
28
|
+
global _confirm_override
|
|
29
|
+
_confirm_override = handler
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def default_confirm(prompt: str) -> bool:
|
|
33
|
+
"""Ask the user to type ``y`` to approve a sensitive action.
|
|
34
|
+
|
|
35
|
+
Returns ``False`` on EOF/Ctrl-C so that "no answer" always means "do not
|
|
36
|
+
proceed". Tools accept this as an injectable dependency so tests can supply
|
|
37
|
+
a deterministic callback instead of blocking on real input.
|
|
38
|
+
"""
|
|
39
|
+
if _confirm_override is not None:
|
|
40
|
+
return bool(_confirm_override(prompt))
|
|
41
|
+
_console.print(f"[yellow]{prompt}[/yellow]")
|
|
42
|
+
try:
|
|
43
|
+
answer = _console.input("Type 'y' to confirm: ")
|
|
44
|
+
except (EOFError, KeyboardInterrupt):
|
|
45
|
+
return False
|
|
46
|
+
return answer.strip().lower() == "y"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def truncate(text: str, limit: int = 4000) -> str:
|
|
50
|
+
"""Cap tool output so a single result can't overwhelm the model context."""
|
|
51
|
+
if len(text) <= limit:
|
|
52
|
+
return text
|
|
53
|
+
return text[:limit] + f"\n… [truncated {len(text) - limit} more characters]"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ToolError(RuntimeError):
|
|
57
|
+
"""Raised by a tool to signal a recoverable failure to the agent."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Tool(ABC):
|
|
61
|
+
"""Base class for all tools.
|
|
62
|
+
|
|
63
|
+
Subclasses set the class attributes ``name``, ``description`` and
|
|
64
|
+
``parameters`` (a JSON Schema object) and implement :meth:`run`.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
name: str = ""
|
|
68
|
+
description: str = ""
|
|
69
|
+
parameters: dict[str, Any] = {"type": "object", "properties": {}}
|
|
70
|
+
|
|
71
|
+
def to_openai_schema(self) -> dict[str, Any]:
|
|
72
|
+
"""Return the function/tool schema in the shape the model expects."""
|
|
73
|
+
return {
|
|
74
|
+
"type": "function",
|
|
75
|
+
"function": {
|
|
76
|
+
"name": self.name,
|
|
77
|
+
"description": self.description,
|
|
78
|
+
"parameters": self.parameters,
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
def run(self, **kwargs: Any) -> str:
|
|
84
|
+
"""Execute the tool and return a string result for the model.
|
|
85
|
+
|
|
86
|
+
Implementations should return a human/model-readable string even on
|
|
87
|
+
failure (e.g. ``"Error: ..."``); raising is acceptable too — the
|
|
88
|
+
executor wraps every call — but returning is preferred for clarity.
|
|
89
|
+
"""
|
pdo/tools/code.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Code execution tool.
|
|
2
|
+
|
|
3
|
+
Runs Python in a *separate process* with a timeout. This isolates crashes and
|
|
4
|
+
bounds runtime, but it is NOT a security sandbox — the code runs with the same
|
|
5
|
+
permissions as PDO. Use it for calculations and quick data tasks.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .base import Tool, truncate
|
|
14
|
+
from .registry import register_tool
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@register_tool
|
|
18
|
+
class PythonExecTool(Tool):
|
|
19
|
+
name = "python_exec"
|
|
20
|
+
description = (
|
|
21
|
+
"Execute a snippet of Python code in a separate process and return its "
|
|
22
|
+
"stdout/stderr. Good for calculations, parsing, and quick data tasks. "
|
|
23
|
+
"Print results you want to see."
|
|
24
|
+
)
|
|
25
|
+
parameters = {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"properties": {
|
|
28
|
+
"code": {"type": "string", "description": "Python source to run."},
|
|
29
|
+
"timeout": {
|
|
30
|
+
"type": "integer",
|
|
31
|
+
"description": "Maximum seconds to run (default 30).",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
"required": ["code"],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
def run(self, code: str, timeout: int = 30, **_: Any) -> str:
|
|
38
|
+
try:
|
|
39
|
+
completed = subprocess.run(
|
|
40
|
+
[sys.executable, "-c", code],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
timeout=timeout,
|
|
44
|
+
)
|
|
45
|
+
except subprocess.TimeoutExpired:
|
|
46
|
+
return f"Error: code timed out after {timeout}s."
|
|
47
|
+
output = ((completed.stdout or "") + (completed.stderr or "")).strip()
|
|
48
|
+
return f"[exit {completed.returncode}]\n{truncate(output or '(no output)')}"
|
pdo/tools/data.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Database tools: query a SQLite file."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sqlite3
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .base import Tool, truncate
|
|
9
|
+
from .registry import register_tool
|
|
10
|
+
|
|
11
|
+
_MAX_ROWS = 200
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@register_tool
|
|
15
|
+
class SqliteQueryTool(Tool):
|
|
16
|
+
name = "sqlite_query"
|
|
17
|
+
description = (
|
|
18
|
+
"Run a SQL statement against a SQLite database file. SELECTs return rows; "
|
|
19
|
+
"other statements are committed and report the affected row count."
|
|
20
|
+
)
|
|
21
|
+
parameters = {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"db_path": {"type": "string", "description": "Path to the .sqlite/.db file."},
|
|
25
|
+
"query": {"type": "string", "description": "The SQL statement to run."},
|
|
26
|
+
"params": {
|
|
27
|
+
"type": "array",
|
|
28
|
+
"items": {"type": "string"},
|
|
29
|
+
"description": "Optional positional parameters for the query.",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
"required": ["db_path", "query"],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def run(
|
|
36
|
+
self, db_path: str, query: str, params: list[Any] | None = None, **_: Any
|
|
37
|
+
) -> str:
|
|
38
|
+
path = Path(db_path).expanduser()
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return f"Error: database not found: {path}"
|
|
41
|
+
try:
|
|
42
|
+
connection = sqlite3.connect(str(path))
|
|
43
|
+
connection.row_factory = sqlite3.Row
|
|
44
|
+
cursor = connection.execute(query, tuple(params or []))
|
|
45
|
+
if cursor.description: # a result-producing query (SELECT/PRAGMA)
|
|
46
|
+
columns = [d[0] for d in cursor.description]
|
|
47
|
+
rows = cursor.fetchmany(_MAX_ROWS)
|
|
48
|
+
lines = [" | ".join(columns)]
|
|
49
|
+
lines += [" | ".join(str(row[c]) for c in columns) for row in rows]
|
|
50
|
+
connection.close()
|
|
51
|
+
return truncate("\n".join(lines)) if rows else "(no rows)"
|
|
52
|
+
connection.commit()
|
|
53
|
+
affected = cursor.rowcount
|
|
54
|
+
connection.close()
|
|
55
|
+
return f"OK ({affected} row(s) affected)."
|
|
56
|
+
except sqlite3.Error as exc:
|
|
57
|
+
return f"SQLite error: {exc}"
|
pdo/tools/edit.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Targeted file edit tool: replace an exact snippet (safer than full rewrites)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .base import Tool, default_confirm
|
|
8
|
+
from .filesystem import _resolve, _within_cwd
|
|
9
|
+
from .registry import register_tool
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@register_tool
|
|
13
|
+
class EditFileTool(Tool):
|
|
14
|
+
name = "edit_file"
|
|
15
|
+
description = (
|
|
16
|
+
"Replace an exact text snippet in a file with new text — safer than "
|
|
17
|
+
"rewriting the whole file. The old text must appear exactly once; add "
|
|
18
|
+
"surrounding context to make it unique."
|
|
19
|
+
)
|
|
20
|
+
parameters = {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"properties": {
|
|
23
|
+
"path": {"type": "string", "description": "File to edit."},
|
|
24
|
+
"old_string": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "Exact text to replace (must be unique in the file).",
|
|
27
|
+
},
|
|
28
|
+
"new_string": {"type": "string", "description": "Replacement text."},
|
|
29
|
+
},
|
|
30
|
+
"required": ["path", "old_string", "new_string"],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def __init__(self, confirm: Callable[[str], bool] = default_confirm) -> None:
|
|
34
|
+
self._confirm = confirm
|
|
35
|
+
|
|
36
|
+
def run(self, path: str, old_string: str, new_string: str, **_: Any) -> str:
|
|
37
|
+
target = _resolve(path)
|
|
38
|
+
if not target.exists():
|
|
39
|
+
return f"Error: file not found: {target}"
|
|
40
|
+
if not _within_cwd(target) and not self._confirm(
|
|
41
|
+
f"Edit file OUTSIDE the working directory {target}?"
|
|
42
|
+
):
|
|
43
|
+
return "Cancelled: editing outside the working directory was not confirmed."
|
|
44
|
+
|
|
45
|
+
content = target.read_text(encoding="utf-8")
|
|
46
|
+
occurrences = content.count(old_string)
|
|
47
|
+
if occurrences == 0:
|
|
48
|
+
return "Error: old_string was not found in the file."
|
|
49
|
+
if occurrences > 1:
|
|
50
|
+
return (
|
|
51
|
+
f"Error: old_string is not unique ({occurrences} matches). "
|
|
52
|
+
"Add more surrounding context so it matches exactly once."
|
|
53
|
+
)
|
|
54
|
+
target.write_text(content.replace(old_string, new_string), encoding="utf-8")
|
|
55
|
+
return f"Edited {target} (1 replacement)."
|
pdo/tools/filesystem.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Filesystem tools: read, write, append, list and create directories.
|
|
2
|
+
|
|
3
|
+
Writes are sandboxed to the current working directory by default. Writing
|
|
4
|
+
outside it, or overwriting an existing file, requires explicit confirmation —
|
|
5
|
+
the confirmation callback is injectable so it can be mocked in tests.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .base import Tool, default_confirm
|
|
15
|
+
from .registry import register_tool
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Guard against accidentally pasting a huge file into the model context.
|
|
20
|
+
MAX_READ_CHARS = 200_000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve(path: str) -> Path:
|
|
24
|
+
"""Expand ``~`` and resolve to an absolute path."""
|
|
25
|
+
return Path(path).expanduser().resolve()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _within_cwd(path: Path) -> bool:
|
|
29
|
+
"""Return True if ``path`` is inside the current working directory."""
|
|
30
|
+
try:
|
|
31
|
+
path.relative_to(Path.cwd().resolve())
|
|
32
|
+
return True
|
|
33
|
+
except ValueError:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@register_tool
|
|
38
|
+
class ReadFileTool(Tool):
|
|
39
|
+
name = "read_file"
|
|
40
|
+
description = "Read and return the UTF-8 text contents of a file."
|
|
41
|
+
parameters = {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"properties": {
|
|
44
|
+
"path": {"type": "string", "description": "Path to the file to read."}
|
|
45
|
+
},
|
|
46
|
+
"required": ["path"],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def run(self, path: str, **_: Any) -> str:
|
|
50
|
+
target = _resolve(path)
|
|
51
|
+
if not target.exists():
|
|
52
|
+
return f"Error: file not found: {target}"
|
|
53
|
+
if not target.is_file():
|
|
54
|
+
return f"Error: not a file: {target}"
|
|
55
|
+
data = target.read_text(encoding="utf-8", errors="replace")
|
|
56
|
+
if len(data) > MAX_READ_CHARS:
|
|
57
|
+
data = data[:MAX_READ_CHARS] + "\n... [truncated]"
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@register_tool
|
|
62
|
+
class WriteFileTool(Tool):
|
|
63
|
+
name = "write_file"
|
|
64
|
+
description = (
|
|
65
|
+
"Write text to a file (creating parent directories). Asks for "
|
|
66
|
+
"confirmation before overwriting a file or writing outside the working "
|
|
67
|
+
"directory."
|
|
68
|
+
)
|
|
69
|
+
parameters = {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"properties": {
|
|
72
|
+
"path": {"type": "string", "description": "Destination file path."},
|
|
73
|
+
"content": {"type": "string", "description": "Text to write."},
|
|
74
|
+
},
|
|
75
|
+
"required": ["path", "content"],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
def __init__(self, confirm: Callable[[str], bool] = default_confirm) -> None:
|
|
79
|
+
self._confirm = confirm
|
|
80
|
+
|
|
81
|
+
def run(self, path: str, content: str, **_: Any) -> str:
|
|
82
|
+
target = _resolve(path)
|
|
83
|
+
if not _within_cwd(target) and not self._confirm(
|
|
84
|
+
f"Write OUTSIDE the working directory to {target}?"
|
|
85
|
+
):
|
|
86
|
+
return "Cancelled: writing outside the working directory was not confirmed."
|
|
87
|
+
if target.exists() and not self._confirm(f"Overwrite existing file {target}?"):
|
|
88
|
+
return "Cancelled: overwrite was not confirmed."
|
|
89
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
target.write_text(content, encoding="utf-8")
|
|
91
|
+
return f"Wrote {len(content)} characters to {target}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@register_tool
|
|
95
|
+
class AppendFileTool(Tool):
|
|
96
|
+
name = "append_file"
|
|
97
|
+
description = (
|
|
98
|
+
"Append text to a file (creating it if needed). Asks for confirmation "
|
|
99
|
+
"before writing outside the working directory."
|
|
100
|
+
)
|
|
101
|
+
parameters = {
|
|
102
|
+
"type": "object",
|
|
103
|
+
"properties": {
|
|
104
|
+
"path": {"type": "string", "description": "Target file path."},
|
|
105
|
+
"content": {"type": "string", "description": "Text to append."},
|
|
106
|
+
},
|
|
107
|
+
"required": ["path", "content"],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
def __init__(self, confirm: Callable[[str], bool] = default_confirm) -> None:
|
|
111
|
+
self._confirm = confirm
|
|
112
|
+
|
|
113
|
+
def run(self, path: str, content: str, **_: Any) -> str:
|
|
114
|
+
target = _resolve(path)
|
|
115
|
+
if not _within_cwd(target) and not self._confirm(
|
|
116
|
+
f"Append OUTSIDE the working directory to {target}?"
|
|
117
|
+
):
|
|
118
|
+
return "Cancelled: writing outside the working directory was not confirmed."
|
|
119
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
with target.open("a", encoding="utf-8") as handle:
|
|
121
|
+
handle.write(content)
|
|
122
|
+
return f"Appended {len(content)} characters to {target}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@register_tool
|
|
126
|
+
class ListDirTool(Tool):
|
|
127
|
+
name = "list_directory"
|
|
128
|
+
description = "List the entries of a directory (directories are marked)."
|
|
129
|
+
parameters = {
|
|
130
|
+
"type": "object",
|
|
131
|
+
"properties": {
|
|
132
|
+
"path": {
|
|
133
|
+
"type": "string",
|
|
134
|
+
"description": "Directory to list. Defaults to the current directory.",
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
def run(self, path: str = ".", **_: Any) -> str:
|
|
140
|
+
target = _resolve(path)
|
|
141
|
+
if not target.exists():
|
|
142
|
+
return f"Error: directory not found: {target}"
|
|
143
|
+
if not target.is_dir():
|
|
144
|
+
return f"Error: not a directory: {target}"
|
|
145
|
+
entries = sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
|
|
146
|
+
lines = [f"{'[dir] ' if entry.is_dir() else ' '}{entry.name}" for entry in entries]
|
|
147
|
+
return "\n".join(lines) if lines else "(empty directory)"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@register_tool
|
|
151
|
+
class CreateDirTool(Tool):
|
|
152
|
+
name = "create_directory"
|
|
153
|
+
description = (
|
|
154
|
+
"Create a directory (including parents). Asks for confirmation before "
|
|
155
|
+
"creating it outside the working directory."
|
|
156
|
+
)
|
|
157
|
+
parameters = {
|
|
158
|
+
"type": "object",
|
|
159
|
+
"properties": {
|
|
160
|
+
"path": {"type": "string", "description": "Directory path to create."}
|
|
161
|
+
},
|
|
162
|
+
"required": ["path"],
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
def __init__(self, confirm: Callable[[str], bool] = default_confirm) -> None:
|
|
166
|
+
self._confirm = confirm
|
|
167
|
+
|
|
168
|
+
def run(self, path: str, **_: Any) -> str:
|
|
169
|
+
target = _resolve(path)
|
|
170
|
+
if not _within_cwd(target) and not self._confirm(
|
|
171
|
+
f"Create directory OUTSIDE the working directory at {target}?"
|
|
172
|
+
):
|
|
173
|
+
return "Cancelled: creating outside the working directory was not confirmed."
|
|
174
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
return f"Created directory {target}"
|