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.
Files changed (130) hide show
  1. pcli/__init__.py +1 -0
  2. pcli/__main__.py +4 -0
  3. pcli/agent/__init__.py +0 -0
  4. pcli/agent/activity.py +116 -0
  5. pcli/agent/compaction.py +205 -0
  6. pcli/agent/context_pruning.py +88 -0
  7. pcli/agent/headless.py +209 -0
  8. pcli/agent/loop.py +442 -0
  9. pcli/agent/prompt.py +371 -0
  10. pcli/agent/runtime.py +240 -0
  11. pcli/browser/__init__.py +0 -0
  12. pcli/browser/session.py +135 -0
  13. pcli/cli.py +757 -0
  14. pcli/config/__init__.py +0 -0
  15. pcli/config/paths.py +95 -0
  16. pcli/config/settings.py +435 -0
  17. pcli/cost/__init__.py +0 -0
  18. pcli/cost/context.py +275 -0
  19. pcli/cost/context_detect.py +183 -0
  20. pcli/cost/pricing_table.py +141 -0
  21. pcli/cost/tracker.py +126 -0
  22. pcli/llm/__init__.py +0 -0
  23. pcli/llm/client.py +285 -0
  24. pcli/llm/errors.py +37 -0
  25. pcli/llm/models.py +100 -0
  26. pcli/llm/streaming.py +108 -0
  27. pcli/memory/__init__.py +0 -0
  28. pcli/memory/extraction.py +106 -0
  29. pcli/memory/models.py +103 -0
  30. pcli/memory/store.py +88 -0
  31. pcli/permissions/__init__.py +0 -0
  32. pcli/permissions/guardrails.py +219 -0
  33. pcli/permissions/manager.py +215 -0
  34. pcli/permissions/policy.py +70 -0
  35. pcli/sandbox/__init__.py +0 -0
  36. pcli/sandbox/base.py +50 -0
  37. pcli/sandbox/docker_backend.py +107 -0
  38. pcli/sandbox/limits.py +63 -0
  39. pcli/sandbox/null_backend.py +92 -0
  40. pcli/sandbox/selector.py +75 -0
  41. pcli/sandbox/subprocess_backend.py +376 -0
  42. pcli/scheduler/__init__.py +0 -0
  43. pcli/scheduler/daemon.py +194 -0
  44. pcli/scheduler/models.py +97 -0
  45. pcli/scheduler/runner.py +84 -0
  46. pcli/scheduler/store.py +75 -0
  47. pcli/scheduler/triggers.py +84 -0
  48. pcli/session/__init__.py +0 -0
  49. pcli/session/audit.py +122 -0
  50. pcli/session/directory_check.py +28 -0
  51. pcli/session/export.py +57 -0
  52. pcli/session/importer.py +92 -0
  53. pcli/session/models.py +168 -0
  54. pcli/session/store.py +127 -0
  55. pcli/telegram/__init__.py +0 -0
  56. pcli/telegram/bot.py +266 -0
  57. pcli/telegram/daemon.py +1197 -0
  58. pcli/telegram/permissions.py +131 -0
  59. pcli/telegram/sender.py +58 -0
  60. pcli/tools/__init__.py +0 -0
  61. pcli/tools/_nested_agent.py +204 -0
  62. pcli/tools/agent_tools.py +264 -0
  63. pcli/tools/agent_tools_store.py +69 -0
  64. pcli/tools/artifacts.py +47 -0
  65. pcli/tools/base.py +185 -0
  66. pcli/tools/builtin/__init__.py +0 -0
  67. pcli/tools/builtin/agent_tool_register_tool.py +100 -0
  68. pcli/tools/builtin/artifact_tool.py +212 -0
  69. pcli/tools/builtin/ask_tool.py +77 -0
  70. pcli/tools/builtin/browser_tool.py +253 -0
  71. pcli/tools/builtin/decision_tool.py +73 -0
  72. pcli/tools/builtin/describe_tool.py +389 -0
  73. pcli/tools/builtin/diff_tools.py +225 -0
  74. pcli/tools/builtin/fs_tools.py +371 -0
  75. pcli/tools/builtin/grep_tool.py +88 -0
  76. pcli/tools/builtin/memory_tool.py +108 -0
  77. pcli/tools/builtin/network_tools.py +107 -0
  78. pcli/tools/builtin/pip_tool.py +106 -0
  79. pcli/tools/builtin/shell_tool.py +240 -0
  80. pcli/tools/builtin/subagent_tool.py +146 -0
  81. pcli/tools/builtin/todo_tool.py +122 -0
  82. pcli/tools/builtin/toolbox_register_tool.py +76 -0
  83. pcli/tools/builtin/web_tools.py +322 -0
  84. pcli/tools/pydiscovery/__init__.py +0 -0
  85. pcli/tools/pydiscovery/cache.py +51 -0
  86. pcli/tools/pydiscovery/index.py +48 -0
  87. pcli/tools/pydiscovery/invoke.py +181 -0
  88. pcli/tools/pydiscovery/search.py +117 -0
  89. pcli/tools/registry.py +138 -0
  90. pcli/tools/toolbox/__init__.py +0 -0
  91. pcli/tools/toolbox/introspect.py +48 -0
  92. pcli/tools/toolbox/manager.py +336 -0
  93. pcli/tools/toolbox/plugin_base.py +51 -0
  94. pcli/tools/toolbox/plugins/__init__.py +6 -0
  95. pcli/tools/toolbox/plugins/httpd.py +99 -0
  96. pcli/tools/toolbox/plugins/kafka.py +162 -0
  97. pcli/tools/toolbox/plugins/kubectl.py +211 -0
  98. pcli/tools/toolbox/plugins/sge.py +146 -0
  99. pcli/tools/toolbox/store.py +65 -0
  100. pcli/tools/toolbox/synthesize.py +100 -0
  101. pcli/tui/__init__.py +0 -0
  102. pcli/tui/app.py +37 -0
  103. pcli/tui/screens/__init__.py +0 -0
  104. pcli/tui/screens/ask_question_modal.py +54 -0
  105. pcli/tui/screens/chat.py +2070 -0
  106. pcli/tui/screens/confirm_modal.py +39 -0
  107. pcli/tui/screens/models.py +43 -0
  108. pcli/tui/screens/permission_modal.py +71 -0
  109. pcli/tui/screens/sessions.py +162 -0
  110. pcli/tui/screens/subagent_activity_modal.py +71 -0
  111. pcli/tui/shell_passthrough.py +56 -0
  112. pcli/tui/styles/pcli.tcss +241 -0
  113. pcli/tui/themes.py +84 -0
  114. pcli/tui/widgets/__init__.py +0 -0
  115. pcli/tui/widgets/chat_input.py +240 -0
  116. pcli/tui/widgets/command_suggestions.py +33 -0
  117. pcli/tui/widgets/message_view.py +328 -0
  118. pcli/tui/widgets/paste_input.py +99 -0
  119. pcli/tui/widgets/paste_marker.py +69 -0
  120. pcli/tui/widgets/status_bar.py +133 -0
  121. pcli/tui/widgets/status_pane.py +58 -0
  122. pcli/util/__init__.py +0 -0
  123. pcli/util/ids.py +15 -0
  124. pcli/util/logging.py +18 -0
  125. pcli/util/text.py +10 -0
  126. pcli_agent-0.1.0.dist-info/METADATA +259 -0
  127. pcli_agent-0.1.0.dist-info/RECORD +130 -0
  128. pcli_agent-0.1.0.dist-info/WHEEL +4 -0
  129. pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  130. 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
@@ -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
+ )