pcli-agent 0.1.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.
- pcli/__init__.py +1 -0
- pcli/__main__.py +4 -0
- pcli/agent/__init__.py +0 -0
- pcli/agent/activity.py +116 -0
- pcli/agent/compaction.py +205 -0
- pcli/agent/context_pruning.py +88 -0
- pcli/agent/headless.py +209 -0
- pcli/agent/loop.py +442 -0
- pcli/agent/prompt.py +371 -0
- pcli/agent/runtime.py +240 -0
- pcli/browser/__init__.py +0 -0
- pcli/browser/session.py +135 -0
- pcli/cli.py +757 -0
- pcli/config/__init__.py +0 -0
- pcli/config/paths.py +95 -0
- pcli/config/settings.py +435 -0
- pcli/cost/__init__.py +0 -0
- pcli/cost/context.py +275 -0
- pcli/cost/context_detect.py +183 -0
- pcli/cost/pricing_table.py +141 -0
- pcli/cost/tracker.py +126 -0
- pcli/llm/__init__.py +0 -0
- pcli/llm/client.py +285 -0
- pcli/llm/errors.py +37 -0
- pcli/llm/models.py +100 -0
- pcli/llm/streaming.py +108 -0
- pcli/memory/__init__.py +0 -0
- pcli/memory/extraction.py +106 -0
- pcli/memory/models.py +103 -0
- pcli/memory/store.py +88 -0
- pcli/permissions/__init__.py +0 -0
- pcli/permissions/guardrails.py +219 -0
- pcli/permissions/manager.py +215 -0
- pcli/permissions/policy.py +70 -0
- pcli/sandbox/__init__.py +0 -0
- pcli/sandbox/base.py +50 -0
- pcli/sandbox/docker_backend.py +107 -0
- pcli/sandbox/limits.py +63 -0
- pcli/sandbox/null_backend.py +92 -0
- pcli/sandbox/selector.py +75 -0
- pcli/sandbox/subprocess_backend.py +376 -0
- pcli/scheduler/__init__.py +0 -0
- pcli/scheduler/daemon.py +194 -0
- pcli/scheduler/models.py +97 -0
- pcli/scheduler/runner.py +84 -0
- pcli/scheduler/store.py +75 -0
- pcli/scheduler/triggers.py +84 -0
- pcli/session/__init__.py +0 -0
- pcli/session/audit.py +122 -0
- pcli/session/directory_check.py +28 -0
- pcli/session/export.py +57 -0
- pcli/session/importer.py +92 -0
- pcli/session/models.py +168 -0
- pcli/session/store.py +127 -0
- pcli/telegram/__init__.py +0 -0
- pcli/telegram/bot.py +266 -0
- pcli/telegram/daemon.py +1197 -0
- pcli/telegram/permissions.py +131 -0
- pcli/telegram/sender.py +58 -0
- pcli/tools/__init__.py +0 -0
- pcli/tools/_nested_agent.py +204 -0
- pcli/tools/agent_tools.py +264 -0
- pcli/tools/agent_tools_store.py +69 -0
- pcli/tools/artifacts.py +47 -0
- pcli/tools/base.py +185 -0
- pcli/tools/builtin/__init__.py +0 -0
- pcli/tools/builtin/agent_tool_register_tool.py +100 -0
- pcli/tools/builtin/artifact_tool.py +212 -0
- pcli/tools/builtin/ask_tool.py +77 -0
- pcli/tools/builtin/browser_tool.py +253 -0
- pcli/tools/builtin/decision_tool.py +73 -0
- pcli/tools/builtin/describe_tool.py +389 -0
- pcli/tools/builtin/diff_tools.py +225 -0
- pcli/tools/builtin/fs_tools.py +371 -0
- pcli/tools/builtin/grep_tool.py +88 -0
- pcli/tools/builtin/memory_tool.py +108 -0
- pcli/tools/builtin/network_tools.py +107 -0
- pcli/tools/builtin/pip_tool.py +106 -0
- pcli/tools/builtin/shell_tool.py +240 -0
- pcli/tools/builtin/subagent_tool.py +146 -0
- pcli/tools/builtin/todo_tool.py +122 -0
- pcli/tools/builtin/toolbox_register_tool.py +76 -0
- pcli/tools/builtin/web_tools.py +322 -0
- pcli/tools/pydiscovery/__init__.py +0 -0
- pcli/tools/pydiscovery/cache.py +51 -0
- pcli/tools/pydiscovery/index.py +48 -0
- pcli/tools/pydiscovery/invoke.py +181 -0
- pcli/tools/pydiscovery/search.py +117 -0
- pcli/tools/registry.py +138 -0
- pcli/tools/toolbox/__init__.py +0 -0
- pcli/tools/toolbox/introspect.py +48 -0
- pcli/tools/toolbox/manager.py +336 -0
- pcli/tools/toolbox/plugin_base.py +51 -0
- pcli/tools/toolbox/plugins/__init__.py +6 -0
- pcli/tools/toolbox/plugins/httpd.py +99 -0
- pcli/tools/toolbox/plugins/kafka.py +162 -0
- pcli/tools/toolbox/plugins/kubectl.py +211 -0
- pcli/tools/toolbox/plugins/sge.py +146 -0
- pcli/tools/toolbox/store.py +65 -0
- pcli/tools/toolbox/synthesize.py +100 -0
- pcli/tui/__init__.py +0 -0
- pcli/tui/app.py +37 -0
- pcli/tui/screens/__init__.py +0 -0
- pcli/tui/screens/ask_question_modal.py +54 -0
- pcli/tui/screens/chat.py +2070 -0
- pcli/tui/screens/confirm_modal.py +39 -0
- pcli/tui/screens/models.py +43 -0
- pcli/tui/screens/permission_modal.py +71 -0
- pcli/tui/screens/sessions.py +162 -0
- pcli/tui/screens/subagent_activity_modal.py +71 -0
- pcli/tui/shell_passthrough.py +56 -0
- pcli/tui/styles/pcli.tcss +241 -0
- pcli/tui/themes.py +84 -0
- pcli/tui/widgets/__init__.py +0 -0
- pcli/tui/widgets/chat_input.py +240 -0
- pcli/tui/widgets/command_suggestions.py +33 -0
- pcli/tui/widgets/message_view.py +328 -0
- pcli/tui/widgets/paste_input.py +99 -0
- pcli/tui/widgets/paste_marker.py +69 -0
- pcli/tui/widgets/status_bar.py +133 -0
- pcli/tui/widgets/status_pane.py +58 -0
- pcli/util/__init__.py +0 -0
- pcli/util/ids.py +15 -0
- pcli/util/logging.py +18 -0
- pcli/util/text.py +10 -0
- pcli_agent-0.1.0.dist-info/METADATA +259 -0
- pcli_agent-0.1.0.dist-info/RECORD +130 -0
- pcli_agent-0.1.0.dist-info/WHEEL +4 -0
- pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""search_python: coarse, in-process, name/summary-only search (tier 1) —
|
|
2
|
+
safe, no imports, no permission needed.
|
|
3
|
+
|
|
4
|
+
inspect_python_module: imports one specific module to list its public
|
|
5
|
+
functions/classes (tier 2) — this DOES import third-party code, so it's
|
|
6
|
+
permission-gated and runs through invoke.run_bootstrap's isolated subprocess.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pcli.tools.base import ToolContext, ToolResult, ToolSpec
|
|
12
|
+
from pcli.tools.pydiscovery.cache import load_or_build_index
|
|
13
|
+
from pcli.tools.pydiscovery.invoke import run_bootstrap
|
|
14
|
+
|
|
15
|
+
_MAX_RESULTS = 50
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def _search_python(arguments: dict, ctx: ToolContext) -> ToolResult:
|
|
19
|
+
query = arguments.get("query", "").strip().lower()
|
|
20
|
+
entries = load_or_build_index()
|
|
21
|
+
matches = [
|
|
22
|
+
e for e in entries if not query or query in e.name.lower() or query in e.summary.lower()
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
if not matches:
|
|
26
|
+
return ToolResult(output="No matching modules/packages found.")
|
|
27
|
+
|
|
28
|
+
lines = [f"{e.name} - {e.summary}" if e.summary else e.name for e in matches[:_MAX_RESULTS]]
|
|
29
|
+
text = "\n".join(lines)
|
|
30
|
+
if len(matches) > _MAX_RESULTS:
|
|
31
|
+
text += f"\n[...{len(matches) - _MAX_RESULTS} more matches not shown, narrow your query...]"
|
|
32
|
+
text += (
|
|
33
|
+
"\n\n(Name/summary index only — nothing was imported. Use inspect_python_module to "
|
|
34
|
+
"see the functions/classes inside a specific module.)"
|
|
35
|
+
)
|
|
36
|
+
return ToolResult(output=text)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
SEARCH_PYTHON = ToolSpec(
|
|
40
|
+
name="search_python",
|
|
41
|
+
description="Search installed Python packages/modules (stdlib + site-packages) by name or "
|
|
42
|
+
"summary, without importing anything. Use this first to find candidate modules, then "
|
|
43
|
+
"inspect_python_module to see what's inside one.",
|
|
44
|
+
parameters={
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {
|
|
47
|
+
"query": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"description": "Keyword(s) to match against module names and package summaries. "
|
|
50
|
+
"Empty string lists everything.",
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"required": ["query"],
|
|
54
|
+
},
|
|
55
|
+
handler=_search_python,
|
|
56
|
+
needs_permission=False,
|
|
57
|
+
plan_mode_safe=True,
|
|
58
|
+
read_only=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _inspect_python_module(arguments: dict, ctx: ToolContext) -> ToolResult:
|
|
63
|
+
module = arguments["module"]
|
|
64
|
+
payload = {"mode": "inspect", "module": module, "query": arguments.get("query", "")}
|
|
65
|
+
try:
|
|
66
|
+
data = await run_bootstrap(ctx, payload)
|
|
67
|
+
except RuntimeError as exc:
|
|
68
|
+
return ToolResult(
|
|
69
|
+
output=f"Failed to inspect module '{module}': {exc}\n"
|
|
70
|
+
"[pcli] Suggestion: run search_python first to confirm the exact module name is "
|
|
71
|
+
"actually installed.",
|
|
72
|
+
is_error=True,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if not data.get("ok"):
|
|
76
|
+
return ToolResult(
|
|
77
|
+
output=f"{data.get('error', 'unknown error')}\n"
|
|
78
|
+
"[pcli] Suggestion: run search_python first to confirm the exact module name is "
|
|
79
|
+
"actually installed.",
|
|
80
|
+
is_error=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
members = data["members"]
|
|
84
|
+
if not members:
|
|
85
|
+
return ToolResult(output=f"No matching members found in module '{module}'.")
|
|
86
|
+
lines = [
|
|
87
|
+
f"{m['kind']} {m['name']}{m['signature']}" + (f" - {m['doc']}" if m["doc"] else "")
|
|
88
|
+
for m in members
|
|
89
|
+
]
|
|
90
|
+
return ToolResult(output="\n".join(lines))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
INSPECT_PYTHON_MODULE = ToolSpec(
|
|
94
|
+
name="inspect_python_module",
|
|
95
|
+
description="Import and introspect a specific Python module to see its public functions "
|
|
96
|
+
"and classes (name, signature, one-line docstring). This actually imports the module "
|
|
97
|
+
"(in an isolated subprocess), so use search_python first to find the right module name.",
|
|
98
|
+
parameters={
|
|
99
|
+
"type": "object",
|
|
100
|
+
"properties": {
|
|
101
|
+
"module": {
|
|
102
|
+
"type": "string",
|
|
103
|
+
"description": "Fully-qualified module name to import, e.g. 'json' or "
|
|
104
|
+
"'xml.etree.ElementTree'.",
|
|
105
|
+
},
|
|
106
|
+
"query": {"type": "string", "description": "Optional keyword to filter member names."},
|
|
107
|
+
},
|
|
108
|
+
"required": ["module"],
|
|
109
|
+
},
|
|
110
|
+
handler=_inspect_python_module,
|
|
111
|
+
needs_permission=True,
|
|
112
|
+
needs_sandbox=True,
|
|
113
|
+
risk_description="Imports a Python module, which may execute module-level side effects.",
|
|
114
|
+
guardrail_python_module_arg="module",
|
|
115
|
+
plan_mode_safe=True,
|
|
116
|
+
read_only=True,
|
|
117
|
+
)
|
pcli/tools/registry.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Name -> ToolSpec lookup, and the OpenAI `tools=[...]` payload builder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from pcli.llm.models import ToolDefinition
|
|
8
|
+
from pcli.tools.base import ToolSpec
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ToolRegistry:
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._tools: dict[str, ToolSpec] = {}
|
|
14
|
+
|
|
15
|
+
def register(self, tool: ToolSpec) -> None:
|
|
16
|
+
self._tools[tool.name] = tool
|
|
17
|
+
|
|
18
|
+
def merge(self, other: ToolRegistry) -> None:
|
|
19
|
+
for tool in other:
|
|
20
|
+
self.register(tool)
|
|
21
|
+
|
|
22
|
+
def filtered(self, predicate: Callable[[ToolSpec], bool]) -> ToolRegistry:
|
|
23
|
+
"""A new registry containing only the tools predicate(tool) accepts —
|
|
24
|
+
the shared subsetting primitive behind spawn_subagent's allowed_tools,
|
|
25
|
+
plan mode's tool filtering, and agent tools' fixed allowed-tool sets."""
|
|
26
|
+
subset = ToolRegistry()
|
|
27
|
+
for tool in self:
|
|
28
|
+
if predicate(tool):
|
|
29
|
+
subset.register(tool)
|
|
30
|
+
return subset
|
|
31
|
+
|
|
32
|
+
def get(self, name: str) -> ToolSpec | None:
|
|
33
|
+
return self._tools.get(name)
|
|
34
|
+
|
|
35
|
+
def to_openai_tools(self) -> list[ToolDefinition]:
|
|
36
|
+
return [tool.to_openai_tool() for tool in self._tools.values()]
|
|
37
|
+
|
|
38
|
+
def __contains__(self, name: str) -> bool:
|
|
39
|
+
return name in self._tools
|
|
40
|
+
|
|
41
|
+
def __iter__(self):
|
|
42
|
+
return iter(self._tools.values())
|
|
43
|
+
|
|
44
|
+
def __len__(self) -> int:
|
|
45
|
+
return len(self._tools)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_default_registry() -> ToolRegistry:
|
|
49
|
+
from pcli.tools.agent_tools import (
|
|
50
|
+
DATA_ANALYSIS,
|
|
51
|
+
DEEP_RESEARCH,
|
|
52
|
+
EXPLORE_CODEBASE,
|
|
53
|
+
EXPLORE_FILES,
|
|
54
|
+
EXPLORE_LOGS,
|
|
55
|
+
VERIFY_COMPUTATION,
|
|
56
|
+
WRITE_DOCUMENTATION,
|
|
57
|
+
)
|
|
58
|
+
from pcli.tools.builtin.agent_tool_register_tool import REGISTER_AGENT_TOOL
|
|
59
|
+
from pcli.tools.builtin.artifact_tool import ASK_ARTIFACT, FETCH_ARTIFACT
|
|
60
|
+
from pcli.tools.builtin.ask_tool import ASK_USER_QUESTION
|
|
61
|
+
from pcli.tools.builtin.browser_tool import (
|
|
62
|
+
BROWSER_CLICK,
|
|
63
|
+
BROWSER_NAVIGATE,
|
|
64
|
+
BROWSER_PRESS_KEY,
|
|
65
|
+
BROWSER_READ_PAGE,
|
|
66
|
+
BROWSER_SCREENSHOT,
|
|
67
|
+
BROWSER_TYPE,
|
|
68
|
+
BROWSER_WAIT_FOR,
|
|
69
|
+
)
|
|
70
|
+
from pcli.tools.builtin.decision_tool import RECORD_DECISION
|
|
71
|
+
from pcli.tools.builtin.describe_tool import DESCRIBE_TOOL
|
|
72
|
+
from pcli.tools.builtin.diff_tools import APPLY_PATCH, DIFF_FILES
|
|
73
|
+
from pcli.tools.builtin.fs_tools import EDIT_FILE, GLOB_SEARCH, LIST_DIR, READ_FILE, WRITE_FILE
|
|
74
|
+
from pcli.tools.builtin.grep_tool import GREP
|
|
75
|
+
from pcli.tools.builtin.memory_tool import REMEMBER
|
|
76
|
+
from pcli.tools.builtin.network_tools import DOWNLOAD_FILE
|
|
77
|
+
from pcli.tools.builtin.pip_tool import PIP_INSTALL
|
|
78
|
+
from pcli.tools.builtin.shell_tool import (
|
|
79
|
+
READ_BACKGROUND_OUTPUT,
|
|
80
|
+
RUN_SHELL,
|
|
81
|
+
RUN_SHELL_BACKGROUND,
|
|
82
|
+
STOP_BACKGROUND_PROCESS,
|
|
83
|
+
)
|
|
84
|
+
from pcli.tools.builtin.subagent_tool import SPAWN_SUBAGENT
|
|
85
|
+
from pcli.tools.builtin.todo_tool import WRITE_TODOS
|
|
86
|
+
from pcli.tools.builtin.toolbox_register_tool import REGISTER_TOOLBOX_TOOL
|
|
87
|
+
from pcli.tools.builtin.web_tools import WEB_FETCH, WEB_SEARCH
|
|
88
|
+
from pcli.tools.pydiscovery.invoke import CALL_PYTHON
|
|
89
|
+
from pcli.tools.pydiscovery.search import INSPECT_PYTHON_MODULE, SEARCH_PYTHON
|
|
90
|
+
|
|
91
|
+
registry = ToolRegistry()
|
|
92
|
+
for tool in (
|
|
93
|
+
READ_FILE,
|
|
94
|
+
WRITE_FILE,
|
|
95
|
+
EDIT_FILE,
|
|
96
|
+
LIST_DIR,
|
|
97
|
+
GLOB_SEARCH,
|
|
98
|
+
GREP,
|
|
99
|
+
DOWNLOAD_FILE,
|
|
100
|
+
WEB_FETCH,
|
|
101
|
+
WEB_SEARCH,
|
|
102
|
+
DIFF_FILES,
|
|
103
|
+
APPLY_PATCH,
|
|
104
|
+
RUN_SHELL,
|
|
105
|
+
RUN_SHELL_BACKGROUND,
|
|
106
|
+
READ_BACKGROUND_OUTPUT,
|
|
107
|
+
STOP_BACKGROUND_PROCESS,
|
|
108
|
+
PIP_INSTALL,
|
|
109
|
+
SEARCH_PYTHON,
|
|
110
|
+
INSPECT_PYTHON_MODULE,
|
|
111
|
+
CALL_PYTHON,
|
|
112
|
+
SPAWN_SUBAGENT,
|
|
113
|
+
ASK_USER_QUESTION,
|
|
114
|
+
WRITE_TODOS,
|
|
115
|
+
RECORD_DECISION,
|
|
116
|
+
REMEMBER,
|
|
117
|
+
FETCH_ARTIFACT,
|
|
118
|
+
ASK_ARTIFACT,
|
|
119
|
+
REGISTER_TOOLBOX_TOOL,
|
|
120
|
+
EXPLORE_CODEBASE,
|
|
121
|
+
EXPLORE_FILES,
|
|
122
|
+
EXPLORE_LOGS,
|
|
123
|
+
WRITE_DOCUMENTATION,
|
|
124
|
+
VERIFY_COMPUTATION,
|
|
125
|
+
DEEP_RESEARCH,
|
|
126
|
+
DATA_ANALYSIS,
|
|
127
|
+
REGISTER_AGENT_TOOL,
|
|
128
|
+
BROWSER_NAVIGATE,
|
|
129
|
+
BROWSER_CLICK,
|
|
130
|
+
BROWSER_TYPE,
|
|
131
|
+
BROWSER_PRESS_KEY,
|
|
132
|
+
BROWSER_WAIT_FOR,
|
|
133
|
+
BROWSER_READ_PAGE,
|
|
134
|
+
BROWSER_SCREENSHOT,
|
|
135
|
+
DESCRIBE_TOOL,
|
|
136
|
+
):
|
|
137
|
+
registry.register(tool)
|
|
138
|
+
return registry
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Generic `--help` walker for software without a curated plugin. Best-effort
|
|
2
|
+
text corpus collection that feeds into synthesize.py — not a man-page/argparse
|
|
3
|
+
parser, just enough structure-guessing to find likely subcommands."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pcli.sandbox.base import ExecRequest
|
|
11
|
+
from pcli.sandbox.subprocess_backend import RestrictedSubprocessSandbox
|
|
12
|
+
|
|
13
|
+
_SUBCOMMAND_RE = re.compile(r"^\s{2,4}([a-z][a-z0-9_-]{1,30})\s{2,}\S", re.MULTILINE)
|
|
14
|
+
_MAX_SUBCOMMANDS = 8
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def _run_help(invocation: list[str], args: list[str], cwd: Path) -> str:
|
|
18
|
+
sandbox = RestrictedSubprocessSandbox(allowed_roots=[cwd])
|
|
19
|
+
result = await sandbox.execute(
|
|
20
|
+
ExecRequest(command=[*invocation, *args], cwd=cwd, timeout_s=10)
|
|
21
|
+
)
|
|
22
|
+
return (result.stdout + "\n" + result.stderr).strip()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _guess_subcommands(help_text: str) -> list[str]:
|
|
26
|
+
seen: list[str] = []
|
|
27
|
+
for match in _SUBCOMMAND_RE.finditer(help_text):
|
|
28
|
+
word = match.group(1)
|
|
29
|
+
if word not in seen:
|
|
30
|
+
seen.append(word)
|
|
31
|
+
return seen[:_MAX_SUBCOMMANDS]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def collect_help_corpus(invocation: list[str], cwd: Path) -> str:
|
|
35
|
+
"""invocation is the argv prefix used to run the target — [binary_path]
|
|
36
|
+
for a plain executable on PATH, or [sys.executable, script_path] for a
|
|
37
|
+
self-authored .py script that needs an interpreter (see
|
|
38
|
+
ToolboxManager.discover's path= parameter)."""
|
|
39
|
+
label = " ".join(invocation)
|
|
40
|
+
top_help = await _run_help(invocation, ["--help"], cwd)
|
|
41
|
+
parts = [f"$ {label} --help\n{top_help}"]
|
|
42
|
+
|
|
43
|
+
for subcommand in _guess_subcommands(top_help):
|
|
44
|
+
sub_help = await _run_help(invocation, [subcommand, "--help"], cwd)
|
|
45
|
+
if sub_help:
|
|
46
|
+
parts.append(f"$ {label} {subcommand} --help\n{sub_help}")
|
|
47
|
+
|
|
48
|
+
return "\n\n".join(parts)
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""ToolboxManager: user-triggered discovery of OS/software tools, persisted
|
|
2
|
+
across sessions and merged into the shared ToolRegistry on every startup.
|
|
3
|
+
|
|
4
|
+
Discovery is only ever invoked by discover() — nothing here runs
|
|
5
|
+
automatically or in the background."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pcli.llm.client import GatewayClient
|
|
14
|
+
from pcli.sandbox.base import ExecRequest, SandboxSecurityError
|
|
15
|
+
from pcli.sandbox.subprocess_backend import RestrictedSubprocessSandbox
|
|
16
|
+
from pcli.tools.base import ToolContext, ToolResult, ToolSpec
|
|
17
|
+
from pcli.tools.registry import ToolRegistry
|
|
18
|
+
from pcli.tools.toolbox import store
|
|
19
|
+
from pcli.tools.toolbox.introspect import collect_help_corpus
|
|
20
|
+
from pcli.tools.toolbox.plugin_base import CommandSpec, DetectionResult, ToolboxPlugin
|
|
21
|
+
from pcli.tools.toolbox.plugins import ALL_PLUGINS
|
|
22
|
+
from pcli.tools.toolbox.synthesize import synthesize_tools
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ToolboxDiscoveryError(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _plugin_for(software_name: str) -> ToolboxPlugin | None:
|
|
30
|
+
for plugin in ALL_PLUGINS:
|
|
31
|
+
if plugin.software_name == software_name:
|
|
32
|
+
return plugin
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def _detect_primary_binary(
|
|
37
|
+
candidates: tuple[str, ...], version_args: tuple[str, ...], cwd: Path
|
|
38
|
+
) -> tuple[str, str] | None:
|
|
39
|
+
for candidate in candidates:
|
|
40
|
+
binary_path = shutil.which(candidate)
|
|
41
|
+
if not binary_path:
|
|
42
|
+
continue
|
|
43
|
+
sandbox = RestrictedSubprocessSandbox(allowed_roots=[cwd])
|
|
44
|
+
result = await sandbox.execute(
|
|
45
|
+
ExecRequest(command=[binary_path, *version_args], cwd=cwd, timeout_s=10)
|
|
46
|
+
)
|
|
47
|
+
output = (result.stdout + "\n" + result.stderr).strip()
|
|
48
|
+
return binary_path, output
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def make_command_tool_spec(software_name: str, spec: CommandSpec) -> ToolSpec:
|
|
53
|
+
tool_name = f"{software_name}_{spec.name}"
|
|
54
|
+
|
|
55
|
+
async def _handler(arguments: dict, ctx: ToolContext) -> ToolResult:
|
|
56
|
+
argv = [spec.binary_path, *spec.build_args(arguments)]
|
|
57
|
+
result = await ctx.sandbox.execute(ExecRequest(command=argv, cwd=ctx.cwd, timeout_s=30))
|
|
58
|
+
output = result.stdout
|
|
59
|
+
if result.stderr:
|
|
60
|
+
output += f"\n--- stderr ---\n{result.stderr}"
|
|
61
|
+
is_error = result.exit_code != 0 or result.timed_out
|
|
62
|
+
return ToolResult(output=f"[exit_code={result.exit_code}]\n{output}", is_error=is_error)
|
|
63
|
+
|
|
64
|
+
return ToolSpec(
|
|
65
|
+
name=tool_name,
|
|
66
|
+
description=spec.description,
|
|
67
|
+
parameters=spec.parameters,
|
|
68
|
+
handler=_handler,
|
|
69
|
+
needs_permission=spec.risk != "read",
|
|
70
|
+
needs_sandbox=True,
|
|
71
|
+
risk_description=f"Runs `{spec.binary_path}` ({spec.risk}).",
|
|
72
|
+
read_only=spec.risk == "read",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _auto_flags(arguments: dict, parameters: dict) -> list[str]:
|
|
77
|
+
"""Generic best-effort flag builder for LLM-synthesized tools: each
|
|
78
|
+
provided argument becomes `--arg-name value` (or a bare flag for
|
|
79
|
+
booleans). Curated plugins use hand-written build_args instead because
|
|
80
|
+
real CLIs (positional args, short flags, `=` syntax) don't uniformly fit
|
|
81
|
+
this convention — this is the fallback for tools nobody's hand-reviewed."""
|
|
82
|
+
props = parameters.get("properties", {})
|
|
83
|
+
argv: list[str] = []
|
|
84
|
+
for key, value in arguments.items():
|
|
85
|
+
if value is None:
|
|
86
|
+
continue
|
|
87
|
+
flag = "--" + key.replace("_", "-")
|
|
88
|
+
prop_type = (props.get(key) or {}).get("type")
|
|
89
|
+
if prop_type == "boolean":
|
|
90
|
+
if value:
|
|
91
|
+
argv.append(flag)
|
|
92
|
+
continue
|
|
93
|
+
if isinstance(value, list):
|
|
94
|
+
for item in value:
|
|
95
|
+
argv += [flag, str(item)]
|
|
96
|
+
continue
|
|
97
|
+
argv += [flag, str(value)]
|
|
98
|
+
return argv
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _invocation_for_path(path: Path) -> list[str]:
|
|
102
|
+
""".py scripts need an interpreter — Windows can't execve them directly,
|
|
103
|
+
and POSIX only can if they're chmod +x with a shebang, which a
|
|
104
|
+
self-authored script can't be relied on to have."""
|
|
105
|
+
if path.suffix.lower() == ".py":
|
|
106
|
+
return [sys.executable, str(path)]
|
|
107
|
+
return [str(path)]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def make_synthesized_tool_spec(software_name: str, invocation: list[str], tool_data: dict) -> ToolSpec:
|
|
111
|
+
tool_name = f"{software_name}_{tool_data['name']}"
|
|
112
|
+
subcommand: list[str] = tool_data["subcommand"]
|
|
113
|
+
parameters: dict = tool_data["parameters"]
|
|
114
|
+
risk: str = tool_data.get("risk", "mutate")
|
|
115
|
+
|
|
116
|
+
async def _handler(arguments: dict, ctx: ToolContext) -> ToolResult:
|
|
117
|
+
argv = [*invocation, *subcommand, *_auto_flags(arguments, parameters)]
|
|
118
|
+
result = await ctx.sandbox.execute(ExecRequest(command=argv, cwd=ctx.cwd, timeout_s=30))
|
|
119
|
+
output = result.stdout
|
|
120
|
+
if result.stderr:
|
|
121
|
+
output += f"\n--- stderr ---\n{result.stderr}"
|
|
122
|
+
is_error = result.exit_code != 0 or result.timed_out
|
|
123
|
+
return ToolResult(output=f"[exit_code={result.exit_code}]\n{output}", is_error=is_error)
|
|
124
|
+
|
|
125
|
+
invocation_label = " ".join(invocation)
|
|
126
|
+
return ToolSpec(
|
|
127
|
+
name=tool_name,
|
|
128
|
+
description=tool_data["description"]
|
|
129
|
+
+ " (auto-generated from --help output; unreviewed, verify before trusting blindly)",
|
|
130
|
+
parameters=parameters,
|
|
131
|
+
handler=_handler,
|
|
132
|
+
needs_permission=risk != "read",
|
|
133
|
+
needs_sandbox=True,
|
|
134
|
+
risk_description=f"Runs `{invocation_label} {' '.join(subcommand)}` ({risk}, auto-generated).",
|
|
135
|
+
read_only=risk == "read",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ToolboxManager:
|
|
140
|
+
def __init__(self, *, cwd: Path) -> None:
|
|
141
|
+
self._cwd = cwd
|
|
142
|
+
|
|
143
|
+
def _resolve_script_path(self, path: str) -> Path:
|
|
144
|
+
"""Resolves+validates a self-authored script for discover's path=
|
|
145
|
+
parameter: must land inside self._cwd (the same containment
|
|
146
|
+
RestrictedSubprocessSandbox._validate_cwd enforces for every other
|
|
147
|
+
tool), so a path argument can't reach outside the project tree the
|
|
148
|
+
agent is actually working in."""
|
|
149
|
+
candidate = Path(path).expanduser()
|
|
150
|
+
if not candidate.is_absolute():
|
|
151
|
+
candidate = self._cwd / candidate
|
|
152
|
+
resolved = candidate.resolve()
|
|
153
|
+
cwd_resolved = self._cwd.expanduser().resolve()
|
|
154
|
+
if resolved != cwd_resolved and cwd_resolved not in resolved.parents:
|
|
155
|
+
raise SandboxSecurityError(
|
|
156
|
+
f"'{path}' is outside the working directory '{cwd_resolved}'."
|
|
157
|
+
)
|
|
158
|
+
if not resolved.is_file():
|
|
159
|
+
raise ToolboxDiscoveryError(f"'{path}' doesn't exist or isn't a file.")
|
|
160
|
+
return resolved
|
|
161
|
+
|
|
162
|
+
async def discover(
|
|
163
|
+
self,
|
|
164
|
+
software_name: str,
|
|
165
|
+
*,
|
|
166
|
+
gateway_client: GatewayClient | None = None,
|
|
167
|
+
model: str | None = None,
|
|
168
|
+
path: str | None = None,
|
|
169
|
+
) -> str:
|
|
170
|
+
"""path registers a self-authored script directly, bypassing PATH
|
|
171
|
+
lookup entirely — for a model that just wrote its own small tool and
|
|
172
|
+
wants to make it callable without the user placing it on PATH first.
|
|
173
|
+
Curated-plugin matching is skipped when path is given: pointing at a
|
|
174
|
+
specific script means "use exactly this," not "look something up.\""""
|
|
175
|
+
registry = store.read_registry()
|
|
176
|
+
|
|
177
|
+
if path is not None:
|
|
178
|
+
script_path = self._resolve_script_path(path)
|
|
179
|
+
invocation = _invocation_for_path(script_path)
|
|
180
|
+
help_corpus = await collect_help_corpus(invocation, self._cwd)
|
|
181
|
+
if not help_corpus.strip():
|
|
182
|
+
raise ToolboxDiscoveryError(
|
|
183
|
+
f"'{' '.join(invocation)} --help' produced no output to work from."
|
|
184
|
+
)
|
|
185
|
+
corpus_hash = store.hash_corpus(help_corpus)
|
|
186
|
+
|
|
187
|
+
cached = store.read_synthesized_schema(software_name)
|
|
188
|
+
if cached and cached.get("help_corpus_hash") == corpus_hash:
|
|
189
|
+
tools_data = cached["tools"]
|
|
190
|
+
else:
|
|
191
|
+
if gateway_client is None:
|
|
192
|
+
raise ToolboxDiscoveryError(
|
|
193
|
+
f"No LLM gateway configured to synthesize a tool schema for "
|
|
194
|
+
f"'{software_name}'."
|
|
195
|
+
)
|
|
196
|
+
tools_data = await synthesize_tools(
|
|
197
|
+
gateway_client, software_name=software_name, help_corpus=help_corpus, model=model
|
|
198
|
+
)
|
|
199
|
+
store.write_synthesized_schema(
|
|
200
|
+
software_name, help_corpus_hash=corpus_hash, version="unknown", tools=tools_data
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
registry[software_name] = {
|
|
204
|
+
"source": "synthesized",
|
|
205
|
+
"binary_path": str(script_path),
|
|
206
|
+
"invocation": invocation,
|
|
207
|
+
"version": "unknown",
|
|
208
|
+
"tool_count": len(tools_data),
|
|
209
|
+
}
|
|
210
|
+
store.write_registry(registry)
|
|
211
|
+
return (
|
|
212
|
+
f"Registered '{software_name}' from {script_path} — synthesized "
|
|
213
|
+
f"{len(tools_data)} tool(s) from --help output."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
plugin = _plugin_for(software_name)
|
|
217
|
+
|
|
218
|
+
if plugin is not None:
|
|
219
|
+
found = await _detect_primary_binary(plugin.binary_names, plugin.version_args, self._cwd)
|
|
220
|
+
if found is None:
|
|
221
|
+
raise ToolboxDiscoveryError(
|
|
222
|
+
f"Could not find '{software_name}' on PATH "
|
|
223
|
+
f"(tried: {', '.join(plugin.binary_names)})."
|
|
224
|
+
)
|
|
225
|
+
binary_path, version_output = found
|
|
226
|
+
version = plugin.parse_version(version_output) or (0,)
|
|
227
|
+
first_line = version_output.strip().splitlines()[0] if version_output.strip() else "unknown"
|
|
228
|
+
detection = DetectionResult(
|
|
229
|
+
software_name=software_name,
|
|
230
|
+
binary_path=binary_path,
|
|
231
|
+
version_string=first_line,
|
|
232
|
+
version=version,
|
|
233
|
+
)
|
|
234
|
+
tools = plugin.build_tools(detection)
|
|
235
|
+
registry[software_name] = {
|
|
236
|
+
"source": "curated",
|
|
237
|
+
"binary_path": binary_path,
|
|
238
|
+
"version": detection.version_string,
|
|
239
|
+
"tool_count": len(tools),
|
|
240
|
+
}
|
|
241
|
+
store.write_registry(registry)
|
|
242
|
+
return (
|
|
243
|
+
f"Found curated plugin for '{software_name}' at {binary_path} "
|
|
244
|
+
f"(version: {detection.version_string}). Registered {len(tools)} tool(s)."
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
binary_path = shutil.which(software_name)
|
|
248
|
+
if not binary_path:
|
|
249
|
+
raise ToolboxDiscoveryError(f"'{software_name}' was not found on PATH.")
|
|
250
|
+
|
|
251
|
+
help_corpus = await collect_help_corpus([binary_path], self._cwd)
|
|
252
|
+
if not help_corpus.strip():
|
|
253
|
+
raise ToolboxDiscoveryError(f"'{software_name} --help' produced no output to work from.")
|
|
254
|
+
corpus_hash = store.hash_corpus(help_corpus)
|
|
255
|
+
|
|
256
|
+
cached = store.read_synthesized_schema(software_name)
|
|
257
|
+
if cached and cached.get("help_corpus_hash") == corpus_hash:
|
|
258
|
+
tools_data = cached["tools"]
|
|
259
|
+
else:
|
|
260
|
+
if gateway_client is None:
|
|
261
|
+
raise ToolboxDiscoveryError(
|
|
262
|
+
f"No curated plugin for '{software_name}' and no LLM gateway configured "
|
|
263
|
+
"to synthesize one."
|
|
264
|
+
)
|
|
265
|
+
tools_data = await synthesize_tools(
|
|
266
|
+
gateway_client, software_name=software_name, help_corpus=help_corpus, model=model
|
|
267
|
+
)
|
|
268
|
+
store.write_synthesized_schema(
|
|
269
|
+
software_name, help_corpus_hash=corpus_hash, version="unknown", tools=tools_data
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
registry[software_name] = {
|
|
273
|
+
"source": "synthesized",
|
|
274
|
+
"binary_path": binary_path,
|
|
275
|
+
"invocation": [binary_path],
|
|
276
|
+
"version": "unknown",
|
|
277
|
+
"tool_count": len(tools_data),
|
|
278
|
+
}
|
|
279
|
+
store.write_registry(registry)
|
|
280
|
+
return (
|
|
281
|
+
f"No curated plugin for '{software_name}' — introspected {binary_path} and "
|
|
282
|
+
f"synthesized {len(tools_data)} tool(s) from --help output."
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def list_discovered(self) -> dict[str, dict]:
|
|
286
|
+
return store.read_registry()
|
|
287
|
+
|
|
288
|
+
def remove(self, software_name: str) -> None:
|
|
289
|
+
registry = store.read_registry()
|
|
290
|
+
registry.pop(software_name, None)
|
|
291
|
+
store.write_registry(registry)
|
|
292
|
+
|
|
293
|
+
async def load_all(self) -> ToolRegistry:
|
|
294
|
+
"""Rebuilds tool specs for every previously-discovered software.
|
|
295
|
+
Curated entries re-detect live (cheap); synthesized entries load
|
|
296
|
+
their cached schema. Missing binaries are skipped silently rather
|
|
297
|
+
than raising, so a stale registry entry doesn't break startup."""
|
|
298
|
+
registry = ToolRegistry()
|
|
299
|
+
|
|
300
|
+
for software_name, entry in store.read_registry().items():
|
|
301
|
+
plugin = _plugin_for(software_name)
|
|
302
|
+
if plugin is not None:
|
|
303
|
+
found = await _detect_primary_binary(plugin.binary_names, plugin.version_args, self._cwd)
|
|
304
|
+
if found is None:
|
|
305
|
+
continue
|
|
306
|
+
binary_path, version_output = found
|
|
307
|
+
version = plugin.parse_version(version_output) or (0,)
|
|
308
|
+
first_line = (
|
|
309
|
+
version_output.strip().splitlines()[0] if version_output.strip() else "unknown"
|
|
310
|
+
)
|
|
311
|
+
detection = DetectionResult(
|
|
312
|
+
software_name=software_name,
|
|
313
|
+
binary_path=binary_path,
|
|
314
|
+
version_string=first_line,
|
|
315
|
+
version=version,
|
|
316
|
+
)
|
|
317
|
+
for spec in plugin.build_tools(detection):
|
|
318
|
+
registry.register(make_command_tool_spec(software_name, spec))
|
|
319
|
+
else:
|
|
320
|
+
cached = store.read_synthesized_schema(software_name)
|
|
321
|
+
if cached is None:
|
|
322
|
+
continue
|
|
323
|
+
invocation = entry.get("invocation")
|
|
324
|
+
if not invocation:
|
|
325
|
+
# Registry entries written before the invocation field
|
|
326
|
+
# existed only have binary_path - fall back to it.
|
|
327
|
+
binary_path = entry.get("binary_path") or shutil.which(software_name)
|
|
328
|
+
if not binary_path:
|
|
329
|
+
continue
|
|
330
|
+
invocation = [binary_path]
|
|
331
|
+
for tool_data in cached["tools"]:
|
|
332
|
+
registry.register(
|
|
333
|
+
make_synthesized_tool_spec(software_name, invocation, tool_data)
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
return registry
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Plugin architecture for curated per-software tool sets.
|
|
2
|
+
|
|
3
|
+
A plugin declares how to recognize its software (candidate binary names +
|
|
4
|
+
how to run/parse a version check) and, once detected, the specific
|
|
5
|
+
admin/ops/user commands it exposes as tools. Each CommandSpec carries its
|
|
6
|
+
own already-resolved `binary_path` rather than assuming one binary for the
|
|
7
|
+
whole plugin — most software (kubectl, apachectl) is one binary with many
|
|
8
|
+
subcommands, but some (Sun Grid Engine: qstat/qsub/qdel/...) is a suite of
|
|
9
|
+
separate binaries, and this shape covers both without special-casing.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class DetectionResult:
|
|
22
|
+
software_name: str
|
|
23
|
+
binary_path: str
|
|
24
|
+
"""Path to the plugin's primary binary (used to confirm presence/version)."""
|
|
25
|
+
version_string: str
|
|
26
|
+
version: tuple[int, ...]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class CommandSpec:
|
|
31
|
+
name: str
|
|
32
|
+
description: str
|
|
33
|
+
parameters: dict[str, Any]
|
|
34
|
+
"""JSON schema for the tool's arguments."""
|
|
35
|
+
build_args: Callable[[dict[str, Any]], list[str]]
|
|
36
|
+
"""Given validated tool arguments, returns the argv (after the binary)."""
|
|
37
|
+
binary_path: str
|
|
38
|
+
risk: Literal["read", "mutate", "destructive"] = "read"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ToolboxPlugin(ABC):
|
|
42
|
+
software_name: str
|
|
43
|
+
binary_names: tuple[str, ...]
|
|
44
|
+
"""Candidate primary-binary names to look for on PATH, tried in order."""
|
|
45
|
+
version_args: tuple[str, ...] = ("--version",)
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def parse_version(self, version_output: str) -> tuple[int, ...] | None: ...
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def build_tools(self, detection: DetectionResult) -> list[CommandSpec]: ...
|