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,69 @@
|
|
|
1
|
+
"""Persistence for user/model-registered agent tools (see
|
|
2
|
+
tools/agent_tools.py's make_agent_tool and
|
|
3
|
+
tools/builtin/agent_tool_register_tool.py), mirroring tools/toolbox/store.py's
|
|
4
|
+
shape but keyed by tool name rather than software name."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pcli.config.paths import agent_tools_file
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _atomic_write(path: Path, content: str) -> None:
|
|
16
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
18
|
+
tmp_path.write_text(content, encoding="utf-8")
|
|
19
|
+
os.replace(tmp_path, path)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_agent_tools() -> dict[str, dict]:
|
|
23
|
+
path = agent_tools_file()
|
|
24
|
+
if not path.exists():
|
|
25
|
+
return {}
|
|
26
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_agent_tools(agent_tools: dict[str, dict]) -> None:
|
|
30
|
+
_atomic_write(agent_tools_file(), json.dumps(agent_tools, indent=2))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def save_agent_tool(
|
|
34
|
+
name: str,
|
|
35
|
+
*,
|
|
36
|
+
description: str,
|
|
37
|
+
persona_prompt: str,
|
|
38
|
+
allowed_tools: list[str],
|
|
39
|
+
plan_mode_safe: bool,
|
|
40
|
+
) -> None:
|
|
41
|
+
agent_tools = read_agent_tools()
|
|
42
|
+
agent_tools[name] = {
|
|
43
|
+
"description": description,
|
|
44
|
+
"persona_prompt": persona_prompt,
|
|
45
|
+
"allowed_tools": allowed_tools,
|
|
46
|
+
"plan_mode_safe": plan_mode_safe,
|
|
47
|
+
}
|
|
48
|
+
write_agent_tools(agent_tools)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_persisted_agent_tools():
|
|
52
|
+
"""Builds a ToolRegistry from every agent tool saved via save_agent_tool
|
|
53
|
+
— called once at startup (ChatScreen.on_mount) so they survive restarts,
|
|
54
|
+
same as toolbox tools."""
|
|
55
|
+
from pcli.tools.agent_tools import make_agent_tool
|
|
56
|
+
from pcli.tools.registry import ToolRegistry
|
|
57
|
+
|
|
58
|
+
registry = ToolRegistry()
|
|
59
|
+
for name, spec in read_agent_tools().items():
|
|
60
|
+
registry.register(
|
|
61
|
+
make_agent_tool(
|
|
62
|
+
name=name,
|
|
63
|
+
description=spec["description"],
|
|
64
|
+
persona_prompt=spec["persona_prompt"],
|
|
65
|
+
allowed_tool_names=spec["allowed_tools"],
|
|
66
|
+
plan_mode_safe=spec.get("plan_mode_safe", False),
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
return registry
|
pcli/tools/artifacts.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""The 'artifact library': large tool outputs get truncated out of the live
|
|
2
|
+
conversation (see AgentLoop's dispatch) and archived here instead, so they
|
|
3
|
+
don't get re-sent to the model on every subsequent call. The LLM can pull
|
|
4
|
+
one back with the fetch_artifact tool when it actually needs the detail.
|
|
5
|
+
|
|
6
|
+
Backed by the session's own blob storage rather than a separate mechanism,
|
|
7
|
+
so archived content persists/exports/imports exactly like everything else
|
|
8
|
+
in the session — an artifact_id doubles as a ToolInvocation.full_result_ref.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
|
|
15
|
+
from pcli.session.store import SessionStore
|
|
16
|
+
from pcli.util.ids import new_id
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ArtifactStore(Protocol):
|
|
20
|
+
def put(self, content: str) -> str:
|
|
21
|
+
"""Archives content, returning a stable artifact_id to retrieve it by."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def get(self, artifact_id: str) -> str | None:
|
|
25
|
+
"""Returns the archived content, or None if artifact_id is unknown."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SessionArtifactStore:
|
|
30
|
+
def __init__(self, store: SessionStore, session_id: str) -> None:
|
|
31
|
+
self._store = store
|
|
32
|
+
self._session_id = session_id
|
|
33
|
+
|
|
34
|
+
def put(self, content: str) -> str:
|
|
35
|
+
artifact_id = new_id("art_")
|
|
36
|
+
self._store.write_blob_named(self._session_id, self.blob_name_for(artifact_id), content)
|
|
37
|
+
return artifact_id
|
|
38
|
+
|
|
39
|
+
def get(self, artifact_id: str) -> str | None:
|
|
40
|
+
try:
|
|
41
|
+
return self._store.read_blob(self._session_id, self.blob_name_for(artifact_id))
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def blob_name_for(artifact_id: str) -> str:
|
|
47
|
+
return f"{artifact_id}.txt"
|
pcli/tools/base.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Tool definitions: what the LLM can call, and how a call actually runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from pcli.agent.activity import ActivityTracker
|
|
11
|
+
from pcli.llm.client import GatewayClient
|
|
12
|
+
from pcli.llm.models import ToolDefinition, Usage
|
|
13
|
+
from pcli.permissions.guardrails import GuardrailsConfig
|
|
14
|
+
from pcli.permissions.manager import AskCallback, PermissionManager
|
|
15
|
+
from pcli.sandbox.base import Sandbox
|
|
16
|
+
from pcli.session.models import Session
|
|
17
|
+
from pcli.tools.artifacts import ArtifactStore
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from pcli.browser.session import BrowserSession
|
|
21
|
+
from pcli.tools.registry import ToolRegistry
|
|
22
|
+
from pcli.tools.toolbox.manager import ToolboxManager
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
AskQuestionCallback = Callable[[str, list[str] | None], Awaitable[str]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ToolContext:
|
|
30
|
+
sandbox: Sandbox
|
|
31
|
+
guardrails: GuardrailsConfig
|
|
32
|
+
cwd: Path
|
|
33
|
+
# The rest are only populated for the top-level agent loop's context; most
|
|
34
|
+
# tool handlers never touch them. They exist so a tool (namely
|
|
35
|
+
# spawn_subagent) can construct its own nested AgentLoop that shares the
|
|
36
|
+
# parent's gateway/tools/permissions instead of re-plumbing all of this
|
|
37
|
+
# through a parallel context type.
|
|
38
|
+
gateway_client: GatewayClient | None = None
|
|
39
|
+
model: str | None = None
|
|
40
|
+
tool_registry: ToolRegistry | None = None
|
|
41
|
+
permission_manager: PermissionManager | None = None
|
|
42
|
+
ask: AskCallback | None = None
|
|
43
|
+
ask_question: AskQuestionCallback | None = None
|
|
44
|
+
"""Callback for ask_user_question (tools/builtin/ask_tool.py) to pause a
|
|
45
|
+
turn and ask the user something directly - see AskQuestionModal. Kept
|
|
46
|
+
separate from `ask` (permission decisions only, a fixed allow/deny/
|
|
47
|
+
remember-scope shape) since this is a free-form question/answer, not a
|
|
48
|
+
permission choice."""
|
|
49
|
+
max_tool_iterations: int | None = 25
|
|
50
|
+
"""None means unlimited (local-api mode)."""
|
|
51
|
+
subagent_max_iterations: int = 30
|
|
52
|
+
"""Hard ceiling on a nested subagent's own tool-call iterations (see
|
|
53
|
+
Settings.subagent_max_iterations) - unlike max_tool_iterations above,
|
|
54
|
+
this is never None/unlimited, even in local-api mode."""
|
|
55
|
+
max_response_tokens: int | None = None
|
|
56
|
+
"""The parent AgentLoop's current dynamic max_tokens cap (cost/context.py's
|
|
57
|
+
compute_max_response_tokens), threaded through so a nested AgentLoop
|
|
58
|
+
(spawn_subagent, agent_tools.py's make_agent_tool) inherits it instead of
|
|
59
|
+
running with no cap at all — see AgentLoop.max_response_tokens."""
|
|
60
|
+
temperature: float | None = None
|
|
61
|
+
"""The parent AgentLoop's current sampling temperature (Settings.
|
|
62
|
+
default_temperature via /temperature), threaded through for the same
|
|
63
|
+
subagent-inheritance reason as max_response_tokens above."""
|
|
64
|
+
subagent_depth: int = 0
|
|
65
|
+
session: Session | None = None
|
|
66
|
+
"""The live Session object, for tools that read/mutate session-level state
|
|
67
|
+
directly (namely write_todos)."""
|
|
68
|
+
max_session_cost_usd: float | None = None
|
|
69
|
+
"""Settings.max_session_cost_usd, threaded through so a nested AgentLoop
|
|
70
|
+
(spawn_subagent, memory extraction) can build the same cost/tracker.py
|
|
71
|
+
cost_budget_reason(session, max_session_cost_usd) check the parent loop
|
|
72
|
+
uses, instead of a subagent blowing straight through the session's
|
|
73
|
+
budget while the parent's own check is paused waiting for it to
|
|
74
|
+
return."""
|
|
75
|
+
artifact_store: ArtifactStore | None = None
|
|
76
|
+
"""Where large tool outputs get archived (see agent/loop.py's automatic
|
|
77
|
+
truncation) and where fetch_artifact reads them back from."""
|
|
78
|
+
activity: ActivityTracker | None = None
|
|
79
|
+
"""Ephemeral live-progress reporting for the TUI's status pane (namely
|
|
80
|
+
spawn_subagent reporting its own tool-call progress). Not persisted."""
|
|
81
|
+
toolbox_manager: ToolboxManager | None = None
|
|
82
|
+
"""Lets register_toolbox_tool trigger toolbox discovery directly,
|
|
83
|
+
mirroring what the /toolbox discover slash command does."""
|
|
84
|
+
plan_mode: bool = False
|
|
85
|
+
"""True while the session is in plan mode — checked by AgentLoop as a
|
|
86
|
+
dispatch-time backstop (see _dispatch_tool_call) independent of whatever
|
|
87
|
+
registry the caller happened to build, and by spawn_subagent to keep a
|
|
88
|
+
nested subagent from being used as a plan-mode bypass."""
|
|
89
|
+
brave_search_api_key: str = ""
|
|
90
|
+
"""web_search (tools/builtin/web_tools.py) uses this real, supported API
|
|
91
|
+
when configured (non-empty); otherwise it falls back to a best-effort,
|
|
92
|
+
no-API-key scrape of DuckDuckGo's HTML results page."""
|
|
93
|
+
memory_enabled: bool = True
|
|
94
|
+
"""Settings.memory_enabled, threaded through so remember
|
|
95
|
+
(tools/builtin/memory_tool.py) and _nested_agent.py's subagent-prompt
|
|
96
|
+
memory injection don't need to reach for get_settings() themselves."""
|
|
97
|
+
memory_max_entries: int = 40
|
|
98
|
+
"""Settings.memory_max_entries - the cap remember's add_entry() enforces
|
|
99
|
+
(memory/store.py)."""
|
|
100
|
+
browser_session: BrowserSession | None = None
|
|
101
|
+
"""Shared Playwright wrapper for the browser_* tools (tools/builtin/
|
|
102
|
+
browser_tool.py) - one instance per AgentRuntime (agent/runtime.py),
|
|
103
|
+
threaded through unchanged so every call in a session reuses the same
|
|
104
|
+
browser tab/login state. None only for a caller that never set up an
|
|
105
|
+
AgentRuntime at all (e.g. a bare ToolContext built directly in a test)."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class ToolResult:
|
|
110
|
+
output: str
|
|
111
|
+
is_error: bool = False
|
|
112
|
+
extra_usage: list[Usage] = field(default_factory=list)
|
|
113
|
+
"""LLM usage incurred by the tool call itself (e.g. a subagent's own LLM
|
|
114
|
+
calls) that didn't come from the turn's main chat_stream — the caller
|
|
115
|
+
folds this into cost tracking so subagent spend isn't silently dropped."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
ToolHandler = Callable[[dict[str, Any], ToolContext], Awaitable[ToolResult]]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class ToolSpec:
|
|
123
|
+
name: str
|
|
124
|
+
description: str
|
|
125
|
+
parameters: dict[str, Any]
|
|
126
|
+
"""JSON schema for the tool's arguments (the OpenAI-style `function.parameters`)."""
|
|
127
|
+
handler: ToolHandler
|
|
128
|
+
needs_permission: bool = True
|
|
129
|
+
needs_sandbox: bool = False
|
|
130
|
+
risk_description: str = ""
|
|
131
|
+
guardrail_command_arg: str | None = None
|
|
132
|
+
"""Name of the argument holding a shell command, if any — checked against
|
|
133
|
+
the shell denylist regardless of needs_permission."""
|
|
134
|
+
guardrail_path_arg: str | None = None
|
|
135
|
+
"""Name of the argument holding a filesystem path, if any — checked
|
|
136
|
+
against allowed_roots/deny_paths regardless of needs_permission."""
|
|
137
|
+
guardrail_python_module_arg: str | None = None
|
|
138
|
+
"""Name of the argument holding a (possibly dotted) Python module/qualified
|
|
139
|
+
name, if any — its top-level module is checked against the module denylist
|
|
140
|
+
regardless of needs_permission."""
|
|
141
|
+
plan_mode_safe: bool = False
|
|
142
|
+
"""Explicit opt-in for use while plan mode is active. Deliberately NOT
|
|
143
|
+
derived from needs_permission — needs_permission=False is not an accurate
|
|
144
|
+
read-only proxy (e.g. write_todos/record_decision mutate session state
|
|
145
|
+
but don't need permission)."""
|
|
146
|
+
read_only: bool = False
|
|
147
|
+
"""The authoritative "does calling this tool, by itself, mutate
|
|
148
|
+
anything outside its own return value" flag (no filesystem/shell/
|
|
149
|
+
session/memory/registry/browser-state changes) - used by `pcli tools
|
|
150
|
+
list`. Deliberately its own field, not derived from needs_permission or
|
|
151
|
+
plan_mode_safe: neither is an accurate read-only proxy either (see
|
|
152
|
+
plan_mode_safe's own docstring above for the needs_permission case;
|
|
153
|
+
plan_mode_safe=True doesn't imply read-only either - spawn_subagent and
|
|
154
|
+
deep_research are both plan_mode_safe=True but can run arbitrary
|
|
155
|
+
allowed-tool effects, including run_shell, outside of plan mode)."""
|
|
156
|
+
|
|
157
|
+
def to_openai_tool(self) -> ToolDefinition:
|
|
158
|
+
"""The advertised schema gets an extra, optional "purpose" property
|
|
159
|
+
injected on top of self.parameters — a shallow copy, never mutating
|
|
160
|
+
self.parameters itself, which stays the schema AgentLoop validates
|
|
161
|
+
real arguments against (see _dispatch_tool_call, which pops
|
|
162
|
+
"purpose" out before that validation and before the tool handler
|
|
163
|
+
ever sees it — this property exists purely for the model's benefit,
|
|
164
|
+
not as a real tool argument)."""
|
|
165
|
+
parameters = self.parameters
|
|
166
|
+
if "properties" in parameters:
|
|
167
|
+
parameters = {
|
|
168
|
+
**parameters,
|
|
169
|
+
"properties": {
|
|
170
|
+
**parameters["properties"],
|
|
171
|
+
"purpose": {
|
|
172
|
+
"type": "string",
|
|
173
|
+
"description": "Optional: a short, one-sentence reason you're calling "
|
|
174
|
+
"this tool right now (e.g. 'checking whether pdftotext is installed'). "
|
|
175
|
+
"Helps keep tool-call history readable and prunable.",
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
return ToolDefinition(
|
|
180
|
+
function={
|
|
181
|
+
"name": self.name,
|
|
182
|
+
"description": self.description,
|
|
183
|
+
"parameters": parameters,
|
|
184
|
+
}
|
|
185
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""register_agent_tool: lets the LLM define a new named subagent persona,
|
|
2
|
+
restricted to a fixed set of already-existing tools, callable afterward as
|
|
3
|
+
a single tool with one 'query' argument — the same underlying mechanism as
|
|
4
|
+
spawn_subagent (tools/agent_tools.py's make_agent_tool), but with the
|
|
5
|
+
persona/allowed-tools baked in at registration time instead of supplied by
|
|
6
|
+
the calling model on every call. Persisted (tools/agent_tools_store.py) so
|
|
7
|
+
it survives restarts, mirroring register_toolbox_tool but for subagent
|
|
8
|
+
personas instead of external scripts.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pcli.tools.agent_tools import make_agent_tool
|
|
14
|
+
from pcli.tools.agent_tools_store import save_agent_tool
|
|
15
|
+
from pcli.tools.base import ToolContext, ToolResult, ToolSpec
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def _register_agent_tool(arguments: dict, ctx: ToolContext) -> ToolResult:
|
|
19
|
+
if ctx.tool_registry is None:
|
|
20
|
+
return ToolResult(output="Agent tools aren't available in this context.", is_error=True)
|
|
21
|
+
|
|
22
|
+
name = arguments["name"]
|
|
23
|
+
description = arguments["description"]
|
|
24
|
+
persona_prompt = arguments["persona_prompt"]
|
|
25
|
+
allowed_tools = arguments["allowed_tools"]
|
|
26
|
+
plan_mode_safe = arguments.get("plan_mode_safe", False)
|
|
27
|
+
|
|
28
|
+
unknown = [t for t in allowed_tools if t not in ctx.tool_registry]
|
|
29
|
+
if unknown:
|
|
30
|
+
return ToolResult(
|
|
31
|
+
output=f"Unknown tool name(s): {', '.join(unknown)}\n"
|
|
32
|
+
"[pcli] Suggestion: only reference tools that already exist in your own tool set "
|
|
33
|
+
"(check the exact names you were given).",
|
|
34
|
+
is_error=True,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
save_agent_tool(
|
|
38
|
+
name,
|
|
39
|
+
description=description,
|
|
40
|
+
persona_prompt=persona_prompt,
|
|
41
|
+
allowed_tools=allowed_tools,
|
|
42
|
+
plan_mode_safe=plan_mode_safe,
|
|
43
|
+
)
|
|
44
|
+
tool = make_agent_tool(
|
|
45
|
+
name=name,
|
|
46
|
+
description=description,
|
|
47
|
+
persona_prompt=persona_prompt,
|
|
48
|
+
allowed_tool_names=allowed_tools,
|
|
49
|
+
plan_mode_safe=plan_mode_safe,
|
|
50
|
+
)
|
|
51
|
+
ctx.tool_registry.register(tool)
|
|
52
|
+
|
|
53
|
+
return ToolResult(output=f"Registered agent tool '{name}' — callable from now on.")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
REGISTER_AGENT_TOOL = ToolSpec(
|
|
57
|
+
name="register_agent_tool",
|
|
58
|
+
description="Defines a new named subagent persona, callable afterward as a single tool "
|
|
59
|
+
"with one 'query' argument. Use this when you find yourself wanting to delegate the same "
|
|
60
|
+
"kind of focused sub-task repeatedly (a consistent persona and a fixed, restricted set of "
|
|
61
|
+
"tools), rather than repeatedly spelling out the same instructions to spawn_subagent. "
|
|
62
|
+
"Persists across restarts — this is a judgment call for genuinely repetitive delegation, "
|
|
63
|
+
"not every one-off task.",
|
|
64
|
+
parameters={
|
|
65
|
+
"type": "object",
|
|
66
|
+
"properties": {
|
|
67
|
+
"name": {
|
|
68
|
+
"type": "string",
|
|
69
|
+
"description": "Tool name the model will call it by afterward.",
|
|
70
|
+
},
|
|
71
|
+
"description": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"description": "Shown to the calling model as this tool's description.",
|
|
74
|
+
},
|
|
75
|
+
"persona_prompt": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"description": "System prompt for the nested subagent: its role/persona and "
|
|
78
|
+
"how it should approach its task.",
|
|
79
|
+
},
|
|
80
|
+
"allowed_tools": {
|
|
81
|
+
"type": "array",
|
|
82
|
+
"items": {"type": "string"},
|
|
83
|
+
"description": "Fixed set of existing tool names the nested subagent is "
|
|
84
|
+
"restricted to.",
|
|
85
|
+
},
|
|
86
|
+
"plan_mode_safe": {
|
|
87
|
+
"type": "boolean",
|
|
88
|
+
"description": "Whether this new tool should stay usable while plan mode is "
|
|
89
|
+
"active (default false). Only set true if every tool in allowed_tools is "
|
|
90
|
+
"itself read-only/exploration-only.",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
"required": ["name", "description", "persona_prompt", "allowed_tools"],
|
|
94
|
+
},
|
|
95
|
+
handler=_register_agent_tool,
|
|
96
|
+
needs_permission=True,
|
|
97
|
+
risk_description="Registers a new tool the model can call in later turns — grants standing "
|
|
98
|
+
"execution rights to a nested subagent, a bigger action than running one command.",
|
|
99
|
+
read_only=False,
|
|
100
|
+
)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""fetch_artifact: retrieves the full (or a windowed slice of the) content of
|
|
2
|
+
a large tool result that AgentLoop truncated out of the live conversation
|
|
3
|
+
and archived — see pcli.tools.artifacts and agent/loop.py's dispatch logic.
|
|
4
|
+
|
|
5
|
+
ask_artifact (this module too - same domain, one small addition) answers a
|
|
6
|
+
specific question about an archived artifact via a side LLM call instead of
|
|
7
|
+
returning raw content, so a small-context model doesn't have to pull the
|
|
8
|
+
whole thing into its own conversation just to do its own reading
|
|
9
|
+
comprehension over it. Local-api-only (see tui/screens/chat.py, which
|
|
10
|
+
excludes it from the tool registry otherwise): the extra call is free on a
|
|
11
|
+
local gateway, real (if usually small) cost on a paid one."""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from pcli.llm.models import ChatMessage
|
|
18
|
+
from pcli.tools.base import ToolContext, ToolResult, ToolSpec
|
|
19
|
+
|
|
20
|
+
_DEFAULT_FETCH_CHARS = 4000
|
|
21
|
+
_MAX_PATTERN_MATCHES = 50
|
|
22
|
+
_DEFAULT_CONTEXT_LINES = 2
|
|
23
|
+
|
|
24
|
+
_ASK_ARTIFACT_SYSTEM_PROMPT = (
|
|
25
|
+
"Answer the question using only the reference material below. Be concise and direct. If "
|
|
26
|
+
"the material doesn't contain the answer, say so plainly rather than guessing."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _artifact_not_found(artifact_id: str) -> ToolResult:
|
|
31
|
+
return ToolResult(
|
|
32
|
+
output=f"No artifact found with id '{artifact_id}'.\n"
|
|
33
|
+
"[pcli] Suggestion: re-check the \"archived as artifact_id='art_...'\" note in the "
|
|
34
|
+
"original tool result rather than guessing an id — artifact ids aren't derivable "
|
|
35
|
+
"any other way.",
|
|
36
|
+
is_error=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _grep_with_context(
|
|
41
|
+
content: str, pattern: str, context_lines: int, max_chars: int
|
|
42
|
+
) -> tuple[str, int]:
|
|
43
|
+
"""Grep-style search within an already-fetched artifact: finds every
|
|
44
|
+
line matching `pattern`, keeps context_lines of surrounding lines per
|
|
45
|
+
match (like grep -C), and merges overlapping/adjacent windows so
|
|
46
|
+
nearby matches don't duplicate shared lines. Returns (rendered text,
|
|
47
|
+
total match count) — the caller reports the count separately since it
|
|
48
|
+
may exceed what's actually rendered (capped at _MAX_PATTERN_MATCHES
|
|
49
|
+
matches and max_chars of output)."""
|
|
50
|
+
lines = content.splitlines()
|
|
51
|
+
regex = re.compile(pattern)
|
|
52
|
+
match_indices = [i for i, line in enumerate(lines) if regex.search(line)]
|
|
53
|
+
total_matches = len(match_indices)
|
|
54
|
+
|
|
55
|
+
windows: list[tuple[int, int]] = []
|
|
56
|
+
for i in match_indices[:_MAX_PATTERN_MATCHES]:
|
|
57
|
+
start = max(0, i - context_lines)
|
|
58
|
+
end = min(len(lines) - 1, i + context_lines)
|
|
59
|
+
if windows and start <= windows[-1][1] + 1:
|
|
60
|
+
windows[-1] = (windows[-1][0], max(windows[-1][1], end))
|
|
61
|
+
else:
|
|
62
|
+
windows.append((start, end))
|
|
63
|
+
|
|
64
|
+
blocks = [
|
|
65
|
+
"\n".join(f"{idx + 1}: {lines[idx]}" for idx in range(start, end + 1))
|
|
66
|
+
for start, end in windows
|
|
67
|
+
]
|
|
68
|
+
text = "\n--\n".join(blocks)
|
|
69
|
+
if len(text) > max_chars:
|
|
70
|
+
text = text[:max_chars] + "\n[...output truncated to max_chars...]"
|
|
71
|
+
return text, total_matches
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _fetch_artifact(arguments: dict, ctx: ToolContext) -> ToolResult:
|
|
75
|
+
if ctx.artifact_store is None:
|
|
76
|
+
return ToolResult(output="No artifact store available in this context.", is_error=True)
|
|
77
|
+
|
|
78
|
+
artifact_id = arguments["artifact_id"]
|
|
79
|
+
content = ctx.artifact_store.get(artifact_id)
|
|
80
|
+
if content is None:
|
|
81
|
+
return _artifact_not_found(artifact_id)
|
|
82
|
+
|
|
83
|
+
limit = int(arguments.get("limit") or _DEFAULT_FETCH_CHARS)
|
|
84
|
+
pattern = arguments.get("pattern")
|
|
85
|
+
|
|
86
|
+
if pattern:
|
|
87
|
+
try:
|
|
88
|
+
re.compile(pattern)
|
|
89
|
+
except re.error as exc:
|
|
90
|
+
return ToolResult(
|
|
91
|
+
output=f"Invalid regex: {exc}\n"
|
|
92
|
+
"[pcli] Suggestion: if you don't need regex features, escape the special "
|
|
93
|
+
"character(s) or search for a plain substring instead.",
|
|
94
|
+
is_error=True,
|
|
95
|
+
)
|
|
96
|
+
context_lines = int(arguments.get("context_lines") or _DEFAULT_CONTEXT_LINES)
|
|
97
|
+
text, total_matches = _grep_with_context(content, pattern, context_lines, limit)
|
|
98
|
+
if total_matches == 0:
|
|
99
|
+
return ToolResult(output=f"No lines matching {pattern!r} found in this artifact.")
|
|
100
|
+
header = f"{total_matches} matching line(s)"
|
|
101
|
+
if total_matches > _MAX_PATTERN_MATCHES:
|
|
102
|
+
header += f" (showing first {_MAX_PATTERN_MATCHES})"
|
|
103
|
+
return ToolResult(output=f"{header}:\n{text}")
|
|
104
|
+
|
|
105
|
+
offset = max(0, int(arguments.get("offset") or 0))
|
|
106
|
+
total = len(content)
|
|
107
|
+
window = content[offset : offset + limit]
|
|
108
|
+
end = offset + len(window)
|
|
109
|
+
footer = ""
|
|
110
|
+
if end < total:
|
|
111
|
+
footer = f"\n\n[showing chars {offset}-{end} of {total} total; call again with offset={end} for more]"
|
|
112
|
+
return ToolResult(output=window + footer)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
FETCH_ARTIFACT = ToolSpec(
|
|
116
|
+
name="fetch_artifact",
|
|
117
|
+
description="Retrieve the full content of a large tool result that was truncated out of "
|
|
118
|
+
"the conversation and archived (you'll see a note like \"archived as "
|
|
119
|
+
"artifact_id='art_...'\" when this happens). Supports offset/limit to page through very "
|
|
120
|
+
"large artifacts without pulling the whole thing into context at once, or a grep-style "
|
|
121
|
+
"pattern to jump straight to the part you need.",
|
|
122
|
+
parameters={
|
|
123
|
+
"type": "object",
|
|
124
|
+
"properties": {
|
|
125
|
+
"artifact_id": {"type": "string"},
|
|
126
|
+
"pattern": {
|
|
127
|
+
"type": "string",
|
|
128
|
+
"description": "Regex — if given, returns only matching lines plus "
|
|
129
|
+
"context_lines of surrounding context (like grep -C), instead of a raw "
|
|
130
|
+
"character slice. Prefer this over offset/limit when you know what you're "
|
|
131
|
+
"looking for — offset is ignored when pattern is given.",
|
|
132
|
+
},
|
|
133
|
+
"context_lines": {
|
|
134
|
+
"type": "integer",
|
|
135
|
+
"description": "Lines of context before/after each match when pattern is "
|
|
136
|
+
"given (default 2).",
|
|
137
|
+
},
|
|
138
|
+
"offset": {
|
|
139
|
+
"type": "integer",
|
|
140
|
+
"description": "Character offset to start from (default 0). Ignored when "
|
|
141
|
+
"pattern is given.",
|
|
142
|
+
},
|
|
143
|
+
"limit": {
|
|
144
|
+
"type": "integer",
|
|
145
|
+
"description": "Max characters to return (default 4000). Also caps output "
|
|
146
|
+
"when pattern is given.",
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
"required": ["artifact_id"],
|
|
150
|
+
},
|
|
151
|
+
handler=_fetch_artifact,
|
|
152
|
+
needs_permission=False,
|
|
153
|
+
plan_mode_safe=True,
|
|
154
|
+
read_only=True,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def _ask_artifact(arguments: dict, ctx: ToolContext) -> ToolResult:
|
|
159
|
+
if ctx.artifact_store is None:
|
|
160
|
+
return ToolResult(output="No artifact store available in this context.", is_error=True)
|
|
161
|
+
if ctx.gateway_client is None:
|
|
162
|
+
return ToolResult(output="No gateway available in this context.", is_error=True)
|
|
163
|
+
|
|
164
|
+
artifact_id = arguments["artifact_id"]
|
|
165
|
+
question = arguments["question"]
|
|
166
|
+
content = ctx.artifact_store.get(artifact_id)
|
|
167
|
+
if content is None:
|
|
168
|
+
return _artifact_not_found(artifact_id)
|
|
169
|
+
|
|
170
|
+
if len(content) <= _DEFAULT_FETCH_CHARS:
|
|
171
|
+
# Already small enough that fetch_artifact would return it whole -
|
|
172
|
+
# spending an extra LLM call on it would just add latency (the
|
|
173
|
+
# money's free, the round trip isn't) for no benefit over reading
|
|
174
|
+
# it directly.
|
|
175
|
+
return ToolResult(
|
|
176
|
+
output=f"Artifact is small enough to return directly (no extra LLM call needed):"
|
|
177
|
+
f"\n\n{content}"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
messages = [
|
|
181
|
+
ChatMessage(role="system", content=_ASK_ARTIFACT_SYSTEM_PROMPT),
|
|
182
|
+
ChatMessage(role="user", content=f"Question: {question}\n\nReference material:\n{content}"),
|
|
183
|
+
]
|
|
184
|
+
assistant_message, usage = await ctx.gateway_client.collect(messages, model=ctx.model)
|
|
185
|
+
answer = assistant_message.content or "(no answer produced)"
|
|
186
|
+
return ToolResult(output=answer, extra_usage=[usage])
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
ASK_ARTIFACT = ToolSpec(
|
|
190
|
+
name="ask_artifact",
|
|
191
|
+
description="Answer a specific question about a large archived artifact via a side LLM "
|
|
192
|
+
"call, instead of pulling its raw content into this conversation - only available with a "
|
|
193
|
+
"local gateway (the extra call is free there). Prefer this over fetch_artifact when you "
|
|
194
|
+
"want a targeted answer from a large artifact rather than the raw text itself (small "
|
|
195
|
+
"artifacts are returned directly with no extra call, same as fetch_artifact would).",
|
|
196
|
+
parameters={
|
|
197
|
+
"type": "object",
|
|
198
|
+
"properties": {
|
|
199
|
+
"artifact_id": {"type": "string"},
|
|
200
|
+
"question": {
|
|
201
|
+
"type": "string",
|
|
202
|
+
"description": "What you want to know from this artifact, stated as a "
|
|
203
|
+
"specific question.",
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
"required": ["artifact_id", "question"],
|
|
207
|
+
},
|
|
208
|
+
handler=_ask_artifact,
|
|
209
|
+
needs_permission=False,
|
|
210
|
+
plan_mode_safe=True,
|
|
211
|
+
read_only=True,
|
|
212
|
+
)
|