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/agent/messages.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Message and tool-call data structures.
|
|
2
|
+
|
|
3
|
+
These dataclasses are the in-memory representation of a conversation. They know
|
|
4
|
+
how to serialise themselves into the shape the OpenAI chat API expects, which
|
|
5
|
+
keeps that provider-specific detail out of the rest of the agent.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ToolCall:
|
|
15
|
+
"""A single tool invocation requested by the model.
|
|
16
|
+
|
|
17
|
+
``arguments`` is the raw JSON string exactly as returned by the model; it is
|
|
18
|
+
only parsed at execution time so a malformed payload can be reported as a
|
|
19
|
+
tool error rather than crashing the loop.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
id: str
|
|
23
|
+
name: str
|
|
24
|
+
arguments: str = "{}"
|
|
25
|
+
|
|
26
|
+
def to_openai(self) -> dict[str, Any]:
|
|
27
|
+
return {
|
|
28
|
+
"id": self.id,
|
|
29
|
+
"type": "function",
|
|
30
|
+
"function": {"name": self.name, "arguments": self.arguments},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Message:
|
|
36
|
+
"""A single chat message.
|
|
37
|
+
|
|
38
|
+
A message may be a plain text turn, an assistant turn that requests tool
|
|
39
|
+
calls, or a tool-result turn. The optional fields capture those variants
|
|
40
|
+
without needing subclasses.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
role: str # "system" | "user" | "assistant" | "tool"
|
|
44
|
+
# Either plain text or OpenAI multi-part content (e.g. text + image_url
|
|
45
|
+
# parts for vision models); lists are passed through to the API unchanged.
|
|
46
|
+
content: str | list[dict[str, Any]] | None = None
|
|
47
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
48
|
+
tool_call_id: str | None = None # set on tool-result messages
|
|
49
|
+
name: str | None = None # tool name, set on tool-result messages
|
|
50
|
+
|
|
51
|
+
def to_openai(self) -> dict[str, Any]:
|
|
52
|
+
"""Serialise to the dict shape expected by the OpenAI chat API."""
|
|
53
|
+
data: dict[str, Any] = {"role": self.role}
|
|
54
|
+
|
|
55
|
+
# An assistant message that only requests tool calls legitimately has
|
|
56
|
+
# ``content == None``; every other role needs a string content field.
|
|
57
|
+
if self.content is not None:
|
|
58
|
+
data["content"] = self.content
|
|
59
|
+
elif self.role != "assistant":
|
|
60
|
+
data["content"] = ""
|
|
61
|
+
|
|
62
|
+
if self.tool_calls:
|
|
63
|
+
data["tool_calls"] = [tc.to_openai() for tc in self.tool_calls]
|
|
64
|
+
if self.tool_call_id is not None:
|
|
65
|
+
data["tool_call_id"] = self.tool_call_id
|
|
66
|
+
if self.role == "tool" and self.name:
|
|
67
|
+
data["name"] = self.name
|
|
68
|
+
return data
|
|
69
|
+
|
|
70
|
+
# Convenience constructors keep call sites readable. ----------------------
|
|
71
|
+
@classmethod
|
|
72
|
+
def system(cls, content: str) -> Message:
|
|
73
|
+
return cls(role="system", content=content)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def user(cls, content: str) -> Message:
|
|
77
|
+
return cls(role="user", content=content)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def assistant(
|
|
81
|
+
cls, content: str | None = None, tool_calls: list[ToolCall] | None = None
|
|
82
|
+
) -> Message:
|
|
83
|
+
return cls(role="assistant", content=content, tool_calls=tool_calls or [])
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def tool(cls, content: str, tool_call_id: str, name: str) -> Message:
|
|
87
|
+
return cls(role="tool", content=content, tool_call_id=tool_call_id, name=name)
|
pdo/agent/planner.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Lightweight planner.
|
|
2
|
+
|
|
3
|
+
For multi-step requests the planner asks the model for a short, ordered list of
|
|
4
|
+
concrete steps. The plan is advisory: it is injected as context to keep the
|
|
5
|
+
agent focused, not enforced as a rigid script. This stays thin on purpose —
|
|
6
|
+
v1 does not build a planning framework.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from ..llm import LLMClient
|
|
13
|
+
from .messages import Message
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_PLAN_SYSTEM_PROMPT = (
|
|
18
|
+
"You are a planning assistant. Break the user's goal into a short, ordered "
|
|
19
|
+
"list of concrete, actionable steps (at most six). Reply with only the "
|
|
20
|
+
"numbered list — no preamble, no commentary."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Planner:
|
|
25
|
+
"""Turns a goal into a short list of steps using the LLM."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, llm: LLMClient) -> None:
|
|
28
|
+
self._llm = llm
|
|
29
|
+
|
|
30
|
+
def plan(self, goal: str) -> list[str]:
|
|
31
|
+
"""Return a list of step strings, or an empty list on failure."""
|
|
32
|
+
messages = [Message.system(_PLAN_SYSTEM_PROMPT), Message.user(goal)]
|
|
33
|
+
try:
|
|
34
|
+
response = self._llm.complete(messages, tools=None, stream=False)
|
|
35
|
+
except Exception: # noqa: BLE001 — planning is best-effort
|
|
36
|
+
logger.exception("Planning step failed; continuing without a plan")
|
|
37
|
+
return []
|
|
38
|
+
return [line.strip() for line in (response.content or "").splitlines() if line.strip()]
|
pdo/agent/reviewer.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Thin reviewer.
|
|
2
|
+
|
|
3
|
+
A final sanity check on the answer before it reaches the user. For v1 this just
|
|
4
|
+
guarantees a non-empty reply; it is the natural extension point for richer
|
|
5
|
+
checks later (e.g. verifying claimed actions actually ran).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Reviewer:
|
|
15
|
+
"""Validates and, if necessary, repairs the agent's final answer."""
|
|
16
|
+
|
|
17
|
+
def review(self, user_input: str, answer: str) -> str:
|
|
18
|
+
text = (answer or "").strip()
|
|
19
|
+
if not text:
|
|
20
|
+
logger.warning("Empty final answer; substituting a fallback message")
|
|
21
|
+
return (
|
|
22
|
+
"I wasn't able to produce a response to that. "
|
|
23
|
+
"Could you rephrase or add a little more detail?"
|
|
24
|
+
)
|
|
25
|
+
return text
|
pdo/agent/router.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Thin router.
|
|
2
|
+
|
|
3
|
+
The real routing happens inside the model: it decides — via native tool
|
|
4
|
+
calling — whether any tool is needed. So this router always offers the tools and
|
|
5
|
+
just adds one cheap heuristic on top: should we spend a planning call before the
|
|
6
|
+
main loop? That is reserved for clearly multi-step task requests.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
# Verbs that typically signal a "do something" request rather than a chat.
|
|
14
|
+
_TASK_HINTS = re.compile(
|
|
15
|
+
r"\b(build|create|make|generate|write|implement|set\s?up|install|run|fix|"
|
|
16
|
+
r"refactor|delete|remove|deploy|configure|scaffold|add|migrate|convert)\b",
|
|
17
|
+
re.IGNORECASE,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class RouteDecision:
|
|
23
|
+
"""The router's verdict for a single turn."""
|
|
24
|
+
|
|
25
|
+
expose_tools: bool
|
|
26
|
+
should_plan: bool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Router:
|
|
30
|
+
"""Decides whether tools are offered and whether to pre-plan."""
|
|
31
|
+
|
|
32
|
+
def route(self, user_input: str) -> RouteDecision:
|
|
33
|
+
text = user_input.strip()
|
|
34
|
+
# Plan only for substantial, task-shaped requests; trivial one-liners
|
|
35
|
+
# ("list files") don't benefit from a separate planning round-trip.
|
|
36
|
+
should_plan = bool(_TASK_HINTS.search(text)) and len(text.split()) >= 4
|
|
37
|
+
return RouteDecision(expose_tools=True, should_plan=should_plan)
|
pdo/api.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Embedding API: drive the PDO agent from Python.
|
|
2
|
+
|
|
3
|
+
Example::
|
|
4
|
+
|
|
5
|
+
from pdo import run_agent
|
|
6
|
+
|
|
7
|
+
answer = run_agent("list the markdown files here and summarise the README")
|
|
8
|
+
|
|
9
|
+
Configuration comes from the environment / ``.env`` exactly like the CLI
|
|
10
|
+
(``OPENAI_API_KEY``, ``OPENAI_BASE_URL``, ``OPENAI_MODEL``, …), with keyword
|
|
11
|
+
overrides for scripts. Each call uses an ephemeral memory, so it never touches
|
|
12
|
+
your interactive sessions.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .agent.core import Agent
|
|
20
|
+
from .agent.memory import MemoryStore
|
|
21
|
+
from .config import load_config
|
|
22
|
+
from .llm import LLMClient, OpenAIClient
|
|
23
|
+
from .tools.registry import get_registry
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_agent(
|
|
27
|
+
prompt: str,
|
|
28
|
+
*,
|
|
29
|
+
model: str | None = None,
|
|
30
|
+
api_key: str | None = None,
|
|
31
|
+
base_url: str | None = None,
|
|
32
|
+
temperature: float | None = None,
|
|
33
|
+
planning: bool = False,
|
|
34
|
+
llm: LLMClient | None = None,
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Run one task through the PDO agent and return its final answer.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
prompt: the task or question.
|
|
40
|
+
model / api_key / base_url / temperature: override the env config.
|
|
41
|
+
planning: enable the pre-planning step for multi-step tasks.
|
|
42
|
+
llm: supply a custom :class:`~pdo.llm.LLMClient` (e.g. a mock in tests
|
|
43
|
+
or another provider implementation); overrides the other LLM args.
|
|
44
|
+
"""
|
|
45
|
+
config = load_config()
|
|
46
|
+
if model:
|
|
47
|
+
config.openai_model = model
|
|
48
|
+
if api_key:
|
|
49
|
+
config.openai_api_key = api_key
|
|
50
|
+
if base_url is not None:
|
|
51
|
+
config.openai_base_url = base_url
|
|
52
|
+
if temperature is not None:
|
|
53
|
+
config.temperature = temperature
|
|
54
|
+
|
|
55
|
+
if llm is None:
|
|
56
|
+
llm = OpenAIClient(
|
|
57
|
+
api_key=config.openai_api_key,
|
|
58
|
+
model=config.openai_model,
|
|
59
|
+
temperature=config.temperature,
|
|
60
|
+
base_url=config.openai_base_url,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
memory = MemoryStore(Path(tempfile.mkdtemp(prefix="pdo-api-")))
|
|
64
|
+
agent = Agent(config, llm, get_registry(), memory, planning=planning)
|
|
65
|
+
return agent.run_turn(prompt)
|
pdo/banner.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Pixel-art logo for the startup splash screen.
|
|
2
|
+
|
|
3
|
+
Each letter is a small bitmap (1 = filled pixel). Pixels are drawn as a pair of
|
|
4
|
+
block characters (``██``) so they look roughly square in a terminal, where
|
|
5
|
+
character cells are taller than they are wide.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
# 4-wide x 5-tall bitmaps for the letters we need.
|
|
10
|
+
_LETTERS: dict[str, list[str]] = {
|
|
11
|
+
"P": [
|
|
12
|
+
"1111",
|
|
13
|
+
"1001",
|
|
14
|
+
"1111",
|
|
15
|
+
"1000",
|
|
16
|
+
"1000",
|
|
17
|
+
],
|
|
18
|
+
"D": [
|
|
19
|
+
"1110",
|
|
20
|
+
"1001",
|
|
21
|
+
"1001",
|
|
22
|
+
"1001",
|
|
23
|
+
"1110",
|
|
24
|
+
],
|
|
25
|
+
"O": [
|
|
26
|
+
"1111",
|
|
27
|
+
"1001",
|
|
28
|
+
"1001",
|
|
29
|
+
"1001",
|
|
30
|
+
"1111",
|
|
31
|
+
],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_ON = "██"
|
|
35
|
+
_OFF = " "
|
|
36
|
+
_ROWS = 5
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render_logo(word: str = "PDO", gap: str = " ") -> str:
|
|
40
|
+
"""Render ``word`` as multi-line pixel-block art.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
KeyError: if a character in ``word`` has no defined bitmap.
|
|
44
|
+
"""
|
|
45
|
+
lines = ["" for _ in range(_ROWS)]
|
|
46
|
+
last = len(word) - 1
|
|
47
|
+
for index, char in enumerate(word):
|
|
48
|
+
bitmap = _LETTERS[char.upper()]
|
|
49
|
+
for row in range(_ROWS):
|
|
50
|
+
lines[row] += "".join(_ON if pixel == "1" else _OFF for pixel in bitmap[row])
|
|
51
|
+
if index != last:
|
|
52
|
+
lines[row] += gap
|
|
53
|
+
return "\n".join(lines)
|
pdo/config.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Application configuration.
|
|
2
|
+
|
|
3
|
+
All configuration comes from environment variables (optionally loaded from a
|
|
4
|
+
``.env`` file) and is validated at startup with ``pydantic``. We deliberately do
|
|
5
|
+
not depend on ``pydantic-settings`` to keep the dependency surface small; reading
|
|
6
|
+
the environment by hand is trivial and keeps the failure messages friendly.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from dotenv import load_dotenv
|
|
14
|
+
from pydantic import BaseModel, Field, ValidationError, field_validator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigError(RuntimeError):
|
|
18
|
+
"""Raised when configuration is missing or invalid.
|
|
19
|
+
|
|
20
|
+
The message is intended to be shown directly to the user, so it must be
|
|
21
|
+
human-friendly rather than a stack trace.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Config(BaseModel):
|
|
26
|
+
"""Validated runtime configuration."""
|
|
27
|
+
|
|
28
|
+
openai_api_key: str = Field(..., min_length=1)
|
|
29
|
+
openai_model: str = Field(default="gpt-4.1-mini", min_length=1)
|
|
30
|
+
# Optional override of the API endpoint. Set this to use an OpenAI-compatible
|
|
31
|
+
# provider such as OpenRouter, a local model server, etc. None = OpenAI.
|
|
32
|
+
openai_base_url: str | None = Field(default=None)
|
|
33
|
+
temperature: float = Field(default=0.2, ge=0.0, le=2.0)
|
|
34
|
+
# Render assistant replies as Markdown (vs. raw token streaming).
|
|
35
|
+
render_markdown: bool = Field(default=True)
|
|
36
|
+
# Accent color theme name (see pdo.theme.THEMES).
|
|
37
|
+
theme: str = Field(default="cyan")
|
|
38
|
+
# Per-tool permission policy: tools that are blocked, or require confirmation.
|
|
39
|
+
deny_tools: list[str] = Field(default_factory=list)
|
|
40
|
+
ask_tools: list[str] = Field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
@field_validator("openai_api_key", "openai_model")
|
|
43
|
+
@classmethod
|
|
44
|
+
def _strip(cls, value: str) -> str:
|
|
45
|
+
return (value or "").strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_config() -> Config:
|
|
49
|
+
"""Load and validate configuration from the environment.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ConfigError: if required values are missing or invalid. The message is
|
|
53
|
+
safe to print directly to the terminal.
|
|
54
|
+
"""
|
|
55
|
+
load_dotenv() # no-op if there is no .env file
|
|
56
|
+
|
|
57
|
+
api_key = os.getenv("OPENAI_API_KEY", "").strip()
|
|
58
|
+
if not api_key:
|
|
59
|
+
raise ConfigError(
|
|
60
|
+
"OPENAI_API_KEY is not set.\n\n"
|
|
61
|
+
"Set it before running PDO, for example:\n"
|
|
62
|
+
" export OPENAI_API_KEY=sk-...\n\n"
|
|
63
|
+
"or copy .env.example to .env and fill it in."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
raw_temperature = os.getenv("TEMPERATURE", "0.2") or "0.2"
|
|
67
|
+
try:
|
|
68
|
+
temperature = float(raw_temperature)
|
|
69
|
+
except ValueError as exc:
|
|
70
|
+
raise ConfigError(
|
|
71
|
+
f"TEMPERATURE must be a number between 0 and 2 (got {raw_temperature!r})."
|
|
72
|
+
) from exc
|
|
73
|
+
|
|
74
|
+
markdown_raw = os.getenv("PDO_MARKDOWN", "1").strip().lower()
|
|
75
|
+
render_markdown = markdown_raw not in ("0", "false", "no", "off")
|
|
76
|
+
|
|
77
|
+
def _csv(name: str) -> list[str]:
|
|
78
|
+
return [item.strip() for item in os.getenv(name, "").split(",") if item.strip()]
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
return Config(
|
|
82
|
+
openai_api_key=api_key,
|
|
83
|
+
openai_model=os.getenv("OPENAI_MODEL", "gpt-4.1-mini"),
|
|
84
|
+
openai_base_url=os.getenv("OPENAI_BASE_URL", "").strip() or None,
|
|
85
|
+
temperature=temperature,
|
|
86
|
+
render_markdown=render_markdown,
|
|
87
|
+
theme=os.getenv("PDO_THEME", "cyan").strip() or "cyan",
|
|
88
|
+
deny_tools=_csv("PDO_DENY_TOOLS"),
|
|
89
|
+
ask_tools=_csv("PDO_ASK_TOOLS"),
|
|
90
|
+
)
|
|
91
|
+
except ValidationError as exc:
|
|
92
|
+
# Surface the first, most relevant validation problem in plain language.
|
|
93
|
+
first = exc.errors()[0]
|
|
94
|
+
field = ".".join(str(p) for p in first["loc"])
|
|
95
|
+
raise ConfigError(f"Invalid configuration for {field}: {first['msg']}.") from exc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# --- Filesystem locations -------------------------------------------------- #
|
|
99
|
+
#
|
|
100
|
+
# Runtime state (the JSON memory store and rotating logs) lives under a single
|
|
101
|
+
# "home" directory. By default that is the installed package directory so a
|
|
102
|
+
# freshly cloned repo "just works"; set PDO_HOME to relocate it (e.g. ~/.pdo).
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_home_dir() -> Path:
|
|
106
|
+
"""Return the base directory for PDO runtime state."""
|
|
107
|
+
env = os.getenv("PDO_HOME")
|
|
108
|
+
if env:
|
|
109
|
+
return Path(env).expanduser()
|
|
110
|
+
return Path(__file__).resolve().parent
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_data_dir() -> Path:
|
|
114
|
+
"""Return (creating if needed) the directory holding JSON state files."""
|
|
115
|
+
path = get_home_dir() / "data"
|
|
116
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
return path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_logs_dir() -> Path:
|
|
121
|
+
"""Return (creating if needed) the directory holding log files."""
|
|
122
|
+
path = get_home_dir() / "logs"
|
|
123
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
return path
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_plugins_dir() -> Path:
|
|
128
|
+
"""Return (creating if needed) the directory scanned for user tool plugins.
|
|
129
|
+
|
|
130
|
+
Drop a ``.py`` file defining a ``Tool`` subclass here and PDO loads it on
|
|
131
|
+
startup. Defaults to ``<home>/plugins`` (override the home with ``PDO_HOME``).
|
|
132
|
+
"""
|
|
133
|
+
path = get_home_dir() / "plugins"
|
|
134
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
return path
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_mcp_config_path() -> Path:
|
|
139
|
+
"""Return the path to the MCP servers config file (``<home>/mcp.json``)."""
|
|
140
|
+
return get_home_dir() / "mcp.json"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_skills_dir() -> Path:
|
|
144
|
+
"""Return (creating if needed) the directory scanned for user skills.
|
|
145
|
+
|
|
146
|
+
Each ``.md`` file becomes a reusable slash command (e.g. ``review.md`` →
|
|
147
|
+
``/review``). Defaults to ``<home>/skills``.
|
|
148
|
+
"""
|
|
149
|
+
path = get_home_dir() / "skills"
|
|
150
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
return path
|
pdo/llm.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""LLM abstraction.
|
|
2
|
+
|
|
3
|
+
The core agent depends only on the :class:`LLMClient` interface, never on a
|
|
4
|
+
concrete provider. ``OpenAIClient`` is the single implementation shipped in v1;
|
|
5
|
+
adding another provider (Anthropic, a local model, etc.) means writing a new
|
|
6
|
+
class here without touching the agent.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .agent.messages import Message, ToolCall
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LLMError(RuntimeError):
|
|
22
|
+
"""Raised when a request to the language model fails."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _looks_like_tools_unsupported(exc: Exception) -> bool:
|
|
26
|
+
"""Heuristic: does this provider error mean the model can't use tools?
|
|
27
|
+
|
|
28
|
+
Covers messages like "<model> does not support tools" returned by Ollama and
|
|
29
|
+
similar OpenAI-compatible endpoints for tool-less models.
|
|
30
|
+
"""
|
|
31
|
+
message = str(exc).lower()
|
|
32
|
+
return "tool" in message and ("support" in message or "not supported" in message)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class LLMResponse:
|
|
37
|
+
"""A normalised model response: free-text content and/or tool calls."""
|
|
38
|
+
|
|
39
|
+
content: str = ""
|
|
40
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
41
|
+
# Token usage for this call, if the provider reported it.
|
|
42
|
+
usage: dict[str, int] | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LLMClient(ABC):
|
|
46
|
+
"""Provider-agnostic chat interface used by the agent."""
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def complete(
|
|
50
|
+
self,
|
|
51
|
+
messages: Sequence[Message],
|
|
52
|
+
tools: list[dict[str, Any]] | None = None,
|
|
53
|
+
*,
|
|
54
|
+
stream: bool = False,
|
|
55
|
+
on_token: Callable[[str], None] | None = None,
|
|
56
|
+
) -> LLMResponse:
|
|
57
|
+
"""Send ``messages`` to the model and return its response.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
messages: the conversation so far.
|
|
61
|
+
tools: JSON tool schemas the model may call, or ``None`` to disable
|
|
62
|
+
tool calling for this request.
|
|
63
|
+
stream: when ``True``, emit content tokens via ``on_token`` as they
|
|
64
|
+
arrive (the full response is still returned at the end).
|
|
65
|
+
on_token: callback invoked with each content token while streaming.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OpenAIClient(LLMClient):
|
|
70
|
+
"""OpenAI implementation of :class:`LLMClient` using native tool calling."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
api_key: str,
|
|
75
|
+
model: str,
|
|
76
|
+
temperature: float = 0.2,
|
|
77
|
+
base_url: str | None = None,
|
|
78
|
+
client: Any | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
# ``client`` is injectable so tests can pass a fake without importing
|
|
81
|
+
# the SDK or hitting the network. ``base_url`` targets any OpenAI-
|
|
82
|
+
# compatible endpoint (OpenRouter, a local server, …); None = OpenAI.
|
|
83
|
+
if client is None:
|
|
84
|
+
from openai import OpenAI
|
|
85
|
+
|
|
86
|
+
client = OpenAI(api_key=api_key, base_url=base_url)
|
|
87
|
+
self._client = client
|
|
88
|
+
self._model = model
|
|
89
|
+
self._temperature = temperature
|
|
90
|
+
# Some models (e.g. small local Ollama models) reject tool schemas. Once
|
|
91
|
+
# we learn that, we stop sending tools to avoid repeated failed requests.
|
|
92
|
+
self._supports_tools = True
|
|
93
|
+
|
|
94
|
+
def complete(
|
|
95
|
+
self,
|
|
96
|
+
messages: Sequence[Message],
|
|
97
|
+
tools: list[dict[str, Any]] | None = None,
|
|
98
|
+
*,
|
|
99
|
+
stream: bool = False,
|
|
100
|
+
on_token: Callable[[str], None] | None = None,
|
|
101
|
+
) -> LLMResponse:
|
|
102
|
+
kwargs: dict[str, Any] = {
|
|
103
|
+
"model": self._model,
|
|
104
|
+
"messages": [m.to_openai() for m in messages],
|
|
105
|
+
"temperature": self._temperature,
|
|
106
|
+
}
|
|
107
|
+
if tools and self._supports_tools:
|
|
108
|
+
kwargs["tools"] = tools
|
|
109
|
+
kwargs["tool_choice"] = "auto"
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
return self._request(kwargs, stream, on_token)
|
|
113
|
+
except Exception as exc: # noqa: BLE001 — normalise every provider error
|
|
114
|
+
# Degrade gracefully when the model doesn't support tool calling:
|
|
115
|
+
# drop the tools and retry once as a plain chat request.
|
|
116
|
+
if "tools" in kwargs and _looks_like_tools_unsupported(exc):
|
|
117
|
+
logger.warning("Model %r does not support tools; retrying without them", self._model)
|
|
118
|
+
self._supports_tools = False
|
|
119
|
+
kwargs.pop("tools", None)
|
|
120
|
+
kwargs.pop("tool_choice", None)
|
|
121
|
+
try:
|
|
122
|
+
return self._request(kwargs, stream, on_token)
|
|
123
|
+
except Exception as retry_exc: # noqa: BLE001
|
|
124
|
+
logger.exception("LLM request failed (no-tools retry)")
|
|
125
|
+
raise LLMError(f"LLM request failed: {retry_exc}") from retry_exc
|
|
126
|
+
logger.exception("LLM request failed")
|
|
127
|
+
raise LLMError(f"LLM request failed: {exc}") from exc
|
|
128
|
+
|
|
129
|
+
def _request(
|
|
130
|
+
self, kwargs: dict[str, Any], stream: bool, on_token: Callable[[str], None] | None
|
|
131
|
+
) -> LLMResponse:
|
|
132
|
+
if stream:
|
|
133
|
+
return self._complete_stream(kwargs, on_token)
|
|
134
|
+
return self._complete_once(kwargs)
|
|
135
|
+
|
|
136
|
+
def _complete_once(self, kwargs: dict[str, Any]) -> LLMResponse:
|
|
137
|
+
response = self._client.chat.completions.create(**kwargs)
|
|
138
|
+
message = response.choices[0].message
|
|
139
|
+
tool_calls = [
|
|
140
|
+
ToolCall(id=tc.id, name=tc.function.name, arguments=tc.function.arguments or "{}")
|
|
141
|
+
for tc in (message.tool_calls or [])
|
|
142
|
+
]
|
|
143
|
+
return LLMResponse(
|
|
144
|
+
content=message.content or "",
|
|
145
|
+
tool_calls=tool_calls,
|
|
146
|
+
usage=_usage_to_dict(getattr(response, "usage", None)),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _complete_stream(
|
|
150
|
+
self, kwargs: dict[str, Any], on_token: Callable[[str], None] | None
|
|
151
|
+
) -> LLMResponse:
|
|
152
|
+
# include_usage asks the API to emit a final usage chunk while streaming;
|
|
153
|
+
# some OpenAI-compatible endpoints reject it, so fall back without it.
|
|
154
|
+
try:
|
|
155
|
+
stream = self._client.chat.completions.create(
|
|
156
|
+
**kwargs, stream=True, stream_options={"include_usage": True}
|
|
157
|
+
)
|
|
158
|
+
except TypeError:
|
|
159
|
+
stream = self._client.chat.completions.create(**kwargs, stream=True)
|
|
160
|
+
|
|
161
|
+
content_parts: list[str] = []
|
|
162
|
+
# Tool-call deltas arrive in fragments keyed by ``index``; accumulate
|
|
163
|
+
# them until the stream completes.
|
|
164
|
+
tool_acc: dict[int, dict[str, str]] = {}
|
|
165
|
+
usage = None
|
|
166
|
+
|
|
167
|
+
for chunk in stream:
|
|
168
|
+
if getattr(chunk, "usage", None):
|
|
169
|
+
usage = chunk.usage
|
|
170
|
+
if not chunk.choices:
|
|
171
|
+
continue
|
|
172
|
+
delta = chunk.choices[0].delta
|
|
173
|
+
|
|
174
|
+
text = getattr(delta, "content", None)
|
|
175
|
+
if text:
|
|
176
|
+
content_parts.append(text)
|
|
177
|
+
if on_token:
|
|
178
|
+
on_token(text)
|
|
179
|
+
|
|
180
|
+
for tc in getattr(delta, "tool_calls", None) or []:
|
|
181
|
+
slot = tool_acc.setdefault(tc.index, {"id": "", "name": "", "arguments": ""})
|
|
182
|
+
if tc.id:
|
|
183
|
+
slot["id"] = tc.id
|
|
184
|
+
if tc.function and tc.function.name:
|
|
185
|
+
slot["name"] = tc.function.name
|
|
186
|
+
if tc.function and tc.function.arguments:
|
|
187
|
+
slot["arguments"] += tc.function.arguments
|
|
188
|
+
|
|
189
|
+
tool_calls = [
|
|
190
|
+
ToolCall(
|
|
191
|
+
id=slot["id"] or f"call_{index}",
|
|
192
|
+
name=slot["name"],
|
|
193
|
+
arguments=slot["arguments"] or "{}",
|
|
194
|
+
)
|
|
195
|
+
for index, slot in sorted(tool_acc.items())
|
|
196
|
+
if slot["name"]
|
|
197
|
+
]
|
|
198
|
+
return LLMResponse(
|
|
199
|
+
content="".join(content_parts), tool_calls=tool_calls, usage=_usage_to_dict(usage)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _usage_to_dict(usage: Any) -> dict[str, int] | None:
|
|
204
|
+
"""Normalise a provider usage object into a plain dict, if present."""
|
|
205
|
+
if usage is None:
|
|
206
|
+
return None
|
|
207
|
+
return {
|
|
208
|
+
"prompt_tokens": getattr(usage, "prompt_tokens", 0) or 0,
|
|
209
|
+
"completion_tokens": getattr(usage, "completion_tokens", 0) or 0,
|
|
210
|
+
"total_tokens": getattr(usage, "total_tokens", 0) or 0,
|
|
211
|
+
}
|