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,106 @@
1
+ """pip_install: install Python packages via `python -m pip install`, given
2
+ either package names/specifiers, a requirements-style file, or both."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+
8
+ from pcli.sandbox.base import ExecRequest
9
+ from pcli.sandbox.subprocess_backend import RestrictedSubprocessSandbox
10
+ from pcli.tools.base import ToolContext, ToolResult, ToolSpec
11
+
12
+ _MAX_OUTPUT_CHARS = 100_000
13
+ _DEFAULT_TIMEOUT_S = 120.0
14
+
15
+
16
+ async def _pip_install(arguments: dict, ctx: ToolContext) -> ToolResult:
17
+ packages = arguments.get("packages") or []
18
+ requirements_file = arguments.get("requirements_file")
19
+ if not packages and not requirements_file:
20
+ return ToolResult(
21
+ output="Nothing to install: provide 'packages' (a list of package names/specifiers) "
22
+ "and/or 'requirements_file' (a path to a requirements-style file).",
23
+ is_error=True,
24
+ )
25
+
26
+ # sys.executable is pcli's own host interpreter, which only exists on the
27
+ # filesystem run_shell-style commands land in when the sandbox is the
28
+ # subprocess backend (same machine, no containment). Under the Docker
29
+ # backend that path doesn't resolve inside the container at all - fall
30
+ # back to whatever "python" the image itself provides, same as any other
31
+ # command sent through ctx.sandbox for that backend.
32
+ python_bin = sys.executable if isinstance(ctx.sandbox, RestrictedSubprocessSandbox) else "python"
33
+ argv = [python_bin, "-m", "pip", "install"]
34
+ if requirements_file:
35
+ argv += ["-r", requirements_file]
36
+ argv += list(packages)
37
+
38
+ requested_timeout_s = float(arguments.get("timeout_s", _DEFAULT_TIMEOUT_S))
39
+ max_timeout_s = float(ctx.guardrails.max_shell_timeout_s)
40
+ timeout_s = min(requested_timeout_s, max_timeout_s)
41
+
42
+ # Installing packages inherently needs network access - under the Docker
43
+ # backend, ExecRequest.network=False (the default everywhere else in this
44
+ # package) maps to --network none, which would make every install fail.
45
+ result = await ctx.sandbox.execute(
46
+ ExecRequest(command=argv, cwd=ctx.cwd, timeout_s=timeout_s, network=True)
47
+ )
48
+
49
+ output = result.stdout
50
+ if result.stderr:
51
+ output += f"\n--- stderr ---\n{result.stderr}"
52
+ if len(output) > _MAX_OUTPUT_CHARS:
53
+ output = output[:_MAX_OUTPUT_CHARS] + "\n[...output truncated...]"
54
+ if requested_timeout_s > max_timeout_s:
55
+ output += (
56
+ f"\n[pcli] Requested timeout_s={requested_timeout_s:g} was clamped to the "
57
+ f"guardrail limit of {max_timeout_s:g}s."
58
+ )
59
+
60
+ is_error = result.exit_code != 0 or result.timed_out
61
+ if result.timed_out:
62
+ output += (
63
+ f"\n[pcli] Suggestion: pip install timed out at timeout_s={timeout_s:g}s — retry "
64
+ "with a higher timeout_s (up to the guardrail ceiling) if it just needs more time "
65
+ "to download/build."
66
+ )
67
+
68
+ summary = f"[exit_code={result.exit_code}, backend={result.backend_used}]\n{output}"
69
+ return ToolResult(output=summary, is_error=is_error)
70
+
71
+
72
+ PIP_INSTALL = ToolSpec(
73
+ name="pip_install",
74
+ description="Install Python package(s) via 'python -m pip install' (never a bare 'pip', "
75
+ "which can silently target the wrong interpreter). Give 'packages' (names/specifiers like "
76
+ "'requests' or 'requests==2.31.0'), 'requirements_file' (a path to a requirements.txt-style "
77
+ "file, passed as pip's -r), or both.",
78
+ parameters={
79
+ "type": "object",
80
+ "properties": {
81
+ "packages": {
82
+ "type": "array",
83
+ "items": {"type": "string"},
84
+ "description": "Package names or specifiers to install, e.g. "
85
+ "['requests', 'numpy>=1.26'].",
86
+ },
87
+ "requirements_file": {
88
+ "type": "string",
89
+ "description": "Path to a requirements-style file (one package per line). "
90
+ "Relative paths resolve against the working directory.",
91
+ },
92
+ "timeout_s": {
93
+ "type": "number",
94
+ "description": f"Timeout in seconds (default {_DEFAULT_TIMEOUT_S:g}). Raise this "
95
+ "for large/slow installs, up to the guardrail ceiling (guardrails.toml's "
96
+ "limits.max_shell_timeout_s).",
97
+ },
98
+ },
99
+ },
100
+ handler=_pip_install,
101
+ needs_permission=True,
102
+ needs_sandbox=True,
103
+ risk_description="Installs Python package(s) via pip.",
104
+ guardrail_path_arg="requirements_file",
105
+ read_only=False,
106
+ )
@@ -0,0 +1,240 @@
1
+ """The builtin tools that go through the Sandbox: run_shell (blocking, the
2
+ common case) and run_shell_background/read_background_output/
3
+ stop_background_process (for commands with no natural end, or long enough
4
+ that blocking the turn on them isn't worth it — see
5
+ sandbox/subprocess_backend.py's BackgroundJob)."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from pcli.sandbox.base import ExecRequest, SandboxSecurityError
10
+ from pcli.sandbox.subprocess_backend import RestrictedSubprocessSandbox
11
+ from pcli.tools.base import ToolContext, ToolResult, ToolSpec
12
+
13
+ _MAX_OUTPUT_CHARS = 100_000
14
+ _BACKGROUND_UNSUPPORTED = (
15
+ "Background execution is only supported by the 'subprocess' sandbox backend "
16
+ "(not available with the current backend)."
17
+ )
18
+ _UNKNOWN_JOB_SUGGESTION = (
19
+ "check the \"Started background job '...'\" message from when run_shell_background was "
20
+ "called — job ids aren't recoverable any other way."
21
+ )
22
+
23
+
24
+ def _timeout_suggestion(timeout_s: float, max_timeout_s: float) -> str:
25
+ if timeout_s < max_timeout_s:
26
+ return (
27
+ f"the command timed out at timeout_s={timeout_s:g}s. Try raising timeout_s (up to "
28
+ f"the guardrail ceiling of {max_timeout_s:g}s) if it just needs more time, or use "
29
+ "run_shell_background if it has no natural end (a dev server, a watcher) or you'd "
30
+ "rather keep working while it runs."
31
+ )
32
+ return (
33
+ f"the command timed out even at the guardrail ceiling of {max_timeout_s:g}s. Use "
34
+ "run_shell_background instead — it starts the command without blocking the turn, and "
35
+ "read_background_output lets you check on it."
36
+ )
37
+
38
+
39
+ def _shell_failure_suggestion(output: str) -> str | None:
40
+ """Pattern-matched against a small set of failure signatures confirmed
41
+ from a real debugged session: the identical Windows "'pip' is not
42
+ recognized" and "Python was not found" (python3-vs-python) errors each
43
+ got hit twice in a row before the model self-corrected by trial and
44
+ error, and a ModuleNotFoundError happened despite an earlier pip
45
+ install having reported "already satisfied" - for a different Python
46
+ interpreter than the one actually running the script."""
47
+ lowered = output.lower()
48
+ if "'pip' is not recognized" in lowered or "pip: command not found" in lowered:
49
+ return "'pip' isn't directly on PATH here — use 'python -m pip' instead."
50
+ if "python was not found" in lowered and "microsoft store" in lowered:
51
+ return "this system's Python launcher is 'python', not 'python3' — retry with 'python'."
52
+ if "modulenotfounderror" in lowered:
53
+ return (
54
+ 'a prior "pip install" succeeding doesn\'t guarantee it targeted the same '
55
+ 'interpreter running this script — check which one is actually active: '
56
+ 'python -c "import sys; print(sys.executable)".'
57
+ )
58
+ return None
59
+
60
+
61
+ async def _run_shell(arguments: dict, ctx: ToolContext) -> ToolResult:
62
+ command = arguments["command"]
63
+ requested_timeout_s = float(arguments.get("timeout_s", 30))
64
+ max_timeout_s = float(ctx.guardrails.max_shell_timeout_s)
65
+ timeout_s = min(requested_timeout_s, max_timeout_s)
66
+ request = ExecRequest(command=command, cwd=ctx.cwd, timeout_s=timeout_s)
67
+ result = await ctx.sandbox.execute(request)
68
+
69
+ output = result.stdout
70
+ if result.stderr:
71
+ output += f"\n--- stderr ---\n{result.stderr}"
72
+ if len(output) > _MAX_OUTPUT_CHARS:
73
+ output = output[:_MAX_OUTPUT_CHARS] + "\n[...output truncated...]"
74
+ if requested_timeout_s > max_timeout_s:
75
+ output += (
76
+ f"\n[pcli] Requested timeout_s={requested_timeout_s:g} was clamped to the "
77
+ f"guardrail limit of {max_timeout_s:g}s. For longer-running commands, use "
78
+ "run_shell_background instead."
79
+ )
80
+
81
+ is_error = result.exit_code != 0 or result.timed_out
82
+ if result.timed_out:
83
+ output += f"\n[pcli] Suggestion: {_timeout_suggestion(timeout_s, max_timeout_s)}"
84
+ elif is_error:
85
+ suggestion = _shell_failure_suggestion(output)
86
+ if suggestion:
87
+ output += f"\n[pcli] Suggestion: {suggestion}"
88
+
89
+ summary = f"[exit_code={result.exit_code}, backend={result.backend_used}]\n{output}"
90
+ return ToolResult(output=summary, is_error=is_error)
91
+
92
+
93
+ RUN_SHELL = ToolSpec(
94
+ name="run_shell",
95
+ description="Run a shell command in a sandboxed working directory and wait for it to "
96
+ "finish. Use for builds, tests, git, package managers, etc. For commands with no natural "
97
+ "end (dev servers, watchers) or that may run longer than a few minutes, use "
98
+ "run_shell_background instead of a very large timeout_s.",
99
+ parameters={
100
+ "type": "object",
101
+ "properties": {
102
+ "command": {"type": "string", "description": "The shell command to run."},
103
+ "timeout_s": {
104
+ "type": "number",
105
+ "description": "Timeout in seconds (default 30). Raise this for commands "
106
+ "known to take longer, up to the guardrail ceiling (see guardrails.toml's "
107
+ "limits.max_shell_timeout_s).",
108
+ },
109
+ },
110
+ "required": ["command"],
111
+ },
112
+ handler=_run_shell,
113
+ needs_permission=True,
114
+ needs_sandbox=True,
115
+ risk_description="Executes an arbitrary shell command.",
116
+ guardrail_command_arg="command",
117
+ read_only=False,
118
+ )
119
+
120
+
121
+ async def _run_shell_background(arguments: dict, ctx: ToolContext) -> ToolResult:
122
+ if not isinstance(ctx.sandbox, RestrictedSubprocessSandbox):
123
+ return ToolResult(output=_BACKGROUND_UNSUPPORTED, is_error=True)
124
+ command = arguments["command"]
125
+ try:
126
+ job_id = await ctx.sandbox.start_background(
127
+ command=command, cwd=ctx.cwd, max_jobs=ctx.guardrails.max_background_jobs
128
+ )
129
+ except SandboxSecurityError as exc:
130
+ return ToolResult(output=str(exc), is_error=True)
131
+ return ToolResult(
132
+ output=f"Started background job '{job_id}': {command}\n"
133
+ f"Use read_background_output(job_id='{job_id}') to check on it, and "
134
+ f"stop_background_process(job_id='{job_id}') when you're done with it."
135
+ )
136
+
137
+
138
+ RUN_SHELL_BACKGROUND = ToolSpec(
139
+ name="run_shell_background",
140
+ description="Starts a shell command in the background and returns immediately with a "
141
+ "job_id, instead of blocking until it finishes like run_shell. Use for long-running "
142
+ "commands (dev servers, watchers, long builds/installs/test suites) where you want to "
143
+ "keep working and check on progress later via read_background_output. Only available "
144
+ "when the sandbox backend is 'subprocess'.",
145
+ parameters={
146
+ "type": "object",
147
+ "properties": {"command": {"type": "string", "description": "The shell command to run."}},
148
+ "required": ["command"],
149
+ },
150
+ handler=_run_shell_background,
151
+ needs_permission=True,
152
+ needs_sandbox=True,
153
+ risk_description="Starts a shell command that keeps running in the background.",
154
+ guardrail_command_arg="command",
155
+ read_only=False,
156
+ )
157
+
158
+
159
+ async def _read_background_output(arguments: dict, ctx: ToolContext) -> ToolResult:
160
+ if not isinstance(ctx.sandbox, RestrictedSubprocessSandbox):
161
+ return ToolResult(output=_BACKGROUND_UNSUPPORTED, is_error=True)
162
+ job_id = arguments["job_id"]
163
+ max_chars = int(arguments.get("max_chars", 4000))
164
+ reset = bool(arguments.get("reset", False))
165
+ read = ctx.sandbox.read_background(job_id, max_chars=max_chars, reset=reset)
166
+ if read is None:
167
+ return ToolResult(
168
+ output=f"No background job with id '{job_id}'.\n[pcli] Suggestion: {_UNKNOWN_JOB_SUGGESTION}",
169
+ is_error=True,
170
+ )
171
+ job, new_stdout, new_stderr, has_more = read
172
+
173
+ status = "running" if job.running else f"exited (exit_code={job.exit_code})"
174
+ lines = [f"status: {status}"]
175
+ if new_stdout:
176
+ lines.append(f"--- new stdout ---\n{new_stdout}")
177
+ if new_stderr:
178
+ lines.append(f"--- new stderr ---\n{new_stderr}")
179
+ if not new_stdout and not new_stderr:
180
+ lines.append("(no new output since your last read)")
181
+ if has_more:
182
+ lines.append("[pcli] More output is available — call again to keep reading.")
183
+ return ToolResult(output="\n".join(lines))
184
+
185
+
186
+ READ_BACKGROUND_OUTPUT = ToolSpec(
187
+ name="read_background_output",
188
+ description="Reads the stdout/stderr a background job (started via run_shell_background) "
189
+ "has produced since your last read of it, and whether it's still running or has exited. "
190
+ "Only returns new output each call — call again if the result says more is available.",
191
+ parameters={
192
+ "type": "object",
193
+ "properties": {
194
+ "job_id": {"type": "string"},
195
+ "max_chars": {
196
+ "type": "integer",
197
+ "description": "Cap on characters returned per stream this call (default 4000).",
198
+ },
199
+ "reset": {
200
+ "type": "boolean",
201
+ "description": "Re-read from the beginning instead of continuing where you left off.",
202
+ },
203
+ },
204
+ "required": ["job_id"],
205
+ },
206
+ handler=_read_background_output,
207
+ needs_permission=False,
208
+ needs_sandbox=True,
209
+ plan_mode_safe=True,
210
+ read_only=True,
211
+ )
212
+
213
+
214
+ async def _stop_background_process(arguments: dict, ctx: ToolContext) -> ToolResult:
215
+ if not isinstance(ctx.sandbox, RestrictedSubprocessSandbox):
216
+ return ToolResult(output=_BACKGROUND_UNSUPPORTED, is_error=True)
217
+ job_id = arguments["job_id"]
218
+ stopped = await ctx.sandbox.stop_background(job_id)
219
+ if not stopped:
220
+ return ToolResult(
221
+ output=f"No background job with id '{job_id}'.\n[pcli] Suggestion: {_UNKNOWN_JOB_SUGGESTION}",
222
+ is_error=True,
223
+ )
224
+ return ToolResult(output=f"Stopped background job '{job_id}'.")
225
+
226
+
227
+ STOP_BACKGROUND_PROCESS = ToolSpec(
228
+ name="stop_background_process",
229
+ description="Kills a background process started via run_shell_background.",
230
+ parameters={
231
+ "type": "object",
232
+ "properties": {"job_id": {"type": "string"}},
233
+ "required": ["job_id"],
234
+ },
235
+ handler=_stop_background_process,
236
+ needs_permission=True,
237
+ needs_sandbox=True,
238
+ risk_description="Kills a running background process.",
239
+ read_only=False,
240
+ )
@@ -0,0 +1,146 @@
1
+ """spawn_subagent: lets the LLM delegate a focused sub-task to a fresh,
2
+ independent agent loop that shares the parent's gateway/sandbox/permissions.
3
+
4
+ Subagents can never spawn further subagents — this tool is always filtered
5
+ out of the tool registry a subagent runs with, so nesting is capped at one
6
+ level by construction, not by a runtime counter that could be bypassed.
7
+
8
+ Only the subagent's final answer and tool-call count are reported back to
9
+ the parent; its own intermediate tool calls are not individually recorded
10
+ in the parent session (they still go through the same permission/guardrail
11
+ gates, just aren't logged as top-level ToolInvocations). Its LLM usage IS
12
+ folded back into cost tracking via ToolResult.extra_usage, since it's real
13
+ spend the session total must reflect.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pcli.tools._nested_agent import context_usage_note, run_nested_agent
19
+ from pcli.tools.base import ToolContext, ToolResult, ToolSpec
20
+ from pcli.tools.builtin.todo_tool import WRITE_TODOS
21
+
22
+ SPAWN_SUBAGENT_TOOL_NAME = "spawn_subagent"
23
+
24
+ _SUBAGENT_SYSTEM_PROMPT = (
25
+ "You are a subagent spawned by another AI agent (pcli) to handle one focused task. "
26
+ "Use the tools available to you to complete it, then give a clear, self-contained final "
27
+ "answer — the parent agent only sees your final text, not your intermediate steps. You "
28
+ "have no memory of the parent conversation beyond the task description you were given."
29
+ )
30
+
31
+
32
+ async def _spawn_subagent(arguments: dict, ctx: ToolContext) -> ToolResult:
33
+ if ctx.gateway_client is None or ctx.tool_registry is None or ctx.permission_manager is None:
34
+ return ToolResult(
35
+ output="Subagents aren't available in this context "
36
+ "(no gateway/tools/permissions configured).\n"
37
+ "[pcli] Suggestion: handle this task directly with the tools you already have "
38
+ "instead of delegating it.",
39
+ is_error=True,
40
+ )
41
+
42
+ task = arguments["task"]
43
+ allowed_tool_names = arguments.get("allowed_tools")
44
+ requested_max_iterations = arguments.get("max_iterations")
45
+ # Subagents keep this structural safety cap even in local-api mode
46
+ # (ctx.max_tool_iterations may be None there, meaning "unlimited" for the
47
+ # parent) — nesting depth/iteration count is a distinct concern from
48
+ # turn/cost limiting, see this module's own docstring.
49
+ if requested_max_iterations:
50
+ max_iterations = min(int(requested_max_iterations), ctx.subagent_max_iterations)
51
+ else:
52
+ max_iterations = ctx.subagent_max_iterations
53
+
54
+ def _allowed(tool: ToolSpec) -> bool:
55
+ if tool.name == SPAWN_SUBAGENT_TOOL_NAME:
56
+ return False # subagents can never spawn further subagents
57
+ if (
58
+ allowed_tool_names is not None
59
+ and tool.name not in allowed_tool_names
60
+ and tool.name != WRITE_TODOS.name
61
+ ):
62
+ # write_todos is always let through regardless of what the calling
63
+ # model's allowed_tools argument specifies (same kind of
64
+ # structural guarantee as the spawn_subagent exclusion above) -
65
+ # the "use write_todos for multi-step work" instruction
66
+ # (_TODO_DISCIPLINE, tools/_nested_agent.py) would otherwise only
67
+ # ever apply when the calling model happens to remember to
68
+ # include it, which a small/unreliable model can't be counted on
69
+ # to do consistently. Harmless even when the model never touches
70
+ # it. Always plan_mode_safe, so this never bypasses plan mode.
71
+ return False
72
+ return not (ctx.plan_mode and not tool.plan_mode_safe)
73
+
74
+ try:
75
+ result = await run_nested_agent(
76
+ ctx,
77
+ system_prompt=_SUBAGENT_SYSTEM_PROMPT,
78
+ task=task,
79
+ activity_label=task,
80
+ allowed=_allowed,
81
+ max_iterations=max_iterations,
82
+ )
83
+ except Exception as exc: # noqa: BLE001 - surface subagent failure, don't crash the parent turn
84
+ return ToolResult(
85
+ output=f"Subagent failed: {exc}\n"
86
+ "[pcli] Suggestion: retry with a narrower, more specific task description, or "
87
+ "handle it directly yourself instead of delegating.",
88
+ is_error=True,
89
+ )
90
+
91
+ result_text = result.final_text or "(subagent produced no final text output)"
92
+ summary = f"[subagent made {result.tool_call_count} tool call(s)]\n{result_text}"
93
+ if result.terminated_early:
94
+ summary = (
95
+ "[pcli] SUBAGENT DID NOT FINISH — it hit its tool-call iteration limit "
96
+ f"({max_iterations}) before completing the task below. Treat this as INCOMPLETE: "
97
+ "do not report the task as done, and verify what (if anything) was actually "
98
+ "produced (e.g. list_dir/read_file the expected output) before telling the user it "
99
+ "succeeded. If the task genuinely needs more tool calls, either raise "
100
+ "subagent_max_iterations via PCLI_SUBAGENT_MAX_ITERATIONS/config.toml, or split the "
101
+ "work into a narrower follow-up task.\n\n" + summary
102
+ )
103
+ note = context_usage_note(result)
104
+ if note:
105
+ summary += "\n\n" + note
106
+ return ToolResult(output=summary, is_error=result.terminated_early, extra_usage=result.usages)
107
+
108
+
109
+ SPAWN_SUBAGENT = ToolSpec(
110
+ name=SPAWN_SUBAGENT_TOOL_NAME,
111
+ description="Delegate a focused sub-task to a fresh subagent, which runs its own "
112
+ "independent tool-calling loop and reports back a final answer. Use this to isolate "
113
+ "exploratory or multi-step work (e.g. 'research how X is implemented in this repo') "
114
+ "without cluttering the main conversation with intermediate tool calls. The subagent "
115
+ "has no memory of this conversation — give it a fully self-contained task description.",
116
+ parameters={
117
+ "type": "object",
118
+ "properties": {
119
+ "task": {
120
+ "type": "string",
121
+ "description": "A clear, self-contained description of what the subagent should "
122
+ "do and what it should report back.",
123
+ },
124
+ "allowed_tools": {
125
+ "type": "array",
126
+ "items": {"type": "string"},
127
+ "description": "Optional: restrict the subagent to only these tool names. Omit "
128
+ "to give it the same tools available to you (it can never spawn further subagents).",
129
+ },
130
+ "max_iterations": {
131
+ "type": "integer",
132
+ "description": "Optional cap on the subagent's own tool-call iterations. There "
133
+ "is always a hard built-in ceiling regardless of this value (configurable via "
134
+ "subagent_max_iterations) - if a task genuinely needs many tool calls (running "
135
+ "several scripts, iterating on errors, generating a report), request a "
136
+ "generous number here rather than assuming the default is enough.",
137
+ },
138
+ },
139
+ "required": ["task"],
140
+ },
141
+ handler=_spawn_subagent,
142
+ needs_permission=True,
143
+ risk_description="Spawns a subagent that can call tools (including sandboxed ones) on its own.",
144
+ plan_mode_safe=True,
145
+ read_only=False,
146
+ )
@@ -0,0 +1,122 @@
1
+ """write_todos: lets the LLM maintain a structured task list for the current
2
+ session. Each call replaces the whole list (like Claude Code's own TodoWrite)
3
+ rather than applying incremental deltas — simpler for the model to get right
4
+ and impossible to desync. The list lives on Session.todos, so it persists,
5
+ exports/imports, and survives resuming a session, same as everything else."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import difflib
10
+
11
+ from pcli.session.models import TodoItem
12
+ from pcli.tools.base import ToolContext, ToolResult, ToolSpec
13
+
14
+ _STATUS_ICONS = {"pending": "[ ]", "in_progress": "[~]", "completed": "[x]"}
15
+
16
+ # Below this similarity ratio, a new todo's content is treated as a
17
+ # different task from an old one, not a reworded version of the same task —
18
+ # see _dropped_completed_items. Threshold picked to tolerate ordinary
19
+ # rewording (e.g. tightening a task's phrasing as it's understood better)
20
+ # while still catching wholesale replacement with an unrelated task.
21
+ _SIMILARITY_THRESHOLD = 0.45
22
+
23
+
24
+ def render_todos(todos: list[TodoItem]) -> str:
25
+ """Real Markdown ("- " bullets) - one caller (the "resuming with
26
+ existing todos" system message in chat.py) renders this through
27
+ rich.markdown.Markdown, which needs actual list-item syntax or it
28
+ collapses multiple "\\n"-joined lines into one run-on paragraph. The
29
+ other caller (this tool's own ToolResult output) renders it as plain
30
+ text instead, where "- " reads fine as a literal bullet too."""
31
+ if not todos:
32
+ return "Todo list is empty."
33
+ lines = [f"- {_STATUS_ICONS.get(t.status, '[ ]')} {t.content}" for t in todos]
34
+ return "\n".join(lines)
35
+
36
+
37
+ def _still_represented(old_content: str, new_contents: list[str]) -> bool:
38
+ return any(
39
+ difflib.SequenceMatcher(None, old_content.lower(), nc.lower()).ratio() >= _SIMILARITY_THRESHOLD
40
+ for nc in new_contents
41
+ )
42
+
43
+
44
+ def _dropped_completed_items(old: list[TodoItem], new: list[TodoItem]) -> list[TodoItem]:
45
+ """Old 'completed' items with no reasonably-similar counterpart in the
46
+ new list — a real debugged case: a model silently replaced a todo list
47
+ that had 4 completed items (real, already-verified work) with 3 brand
48
+ new pending ones for a superficially similar but different task,
49
+ without any explanation, and proceeded to redo the finished work. This
50
+ doesn't block the update (a genuine restart is sometimes correct) — it
51
+ just surfaces the loss so the model can catch its own mistake."""
52
+ new_contents = [t.content for t in new]
53
+ return [t for t in old if t.status == "completed" and not _still_represented(t.content, new_contents)]
54
+
55
+
56
+ async def _write_todos(arguments: dict, ctx: ToolContext) -> ToolResult:
57
+ if ctx.session is None:
58
+ return ToolResult(output="No session available to store todos in.", is_error=True)
59
+
60
+ raw_todos = arguments.get("todos")
61
+ if not isinstance(raw_todos, list):
62
+ return ToolResult(output="'todos' must be a list.", is_error=True)
63
+
64
+ try:
65
+ todos = [TodoItem(content=item["content"], status=item.get("status", "pending")) for item in raw_todos]
66
+ except (KeyError, TypeError) as exc:
67
+ return ToolResult(output=f"Invalid todo entry: {exc}", is_error=True)
68
+
69
+ in_progress_count = sum(1 for t in todos if t.status == "in_progress")
70
+ if in_progress_count > 1:
71
+ return ToolResult(
72
+ output="Only one todo can be 'in_progress' at a time — mark the others "
73
+ "'pending' or 'completed'.",
74
+ is_error=True,
75
+ )
76
+
77
+ dropped = _dropped_completed_items(ctx.session.todos, todos)
78
+ ctx.session.todos = todos
79
+ output = f"Todo list updated:\n{render_todos(todos)}"
80
+ if dropped:
81
+ lost = "; ".join(f'"{t.content}"' for t in dropped[:5])
82
+ output += (
83
+ f"\n\n[pcli] Note: {len(dropped)} previously completed item(s) no longer appear "
84
+ f"in this list ({lost}). If you're deliberately restarting or changing approach, "
85
+ "that's fine — record_decision helps track why. If not, the underlying work may "
86
+ "already be done and doesn't need redoing."
87
+ )
88
+ return ToolResult(output=output)
89
+
90
+
91
+ WRITE_TODOS = ToolSpec(
92
+ name="write_todos",
93
+ description="Create or update your task list for this session. Submit the FULL current "
94
+ "list every time (this replaces it, it doesn't append). Use this for any multi-step "
95
+ "task: write the plan as pending todos before starting, mark exactly one 'in_progress' "
96
+ "while working on it, mark it 'completed' immediately when done, then move to the next. "
97
+ "This keeps the user able to see your progress and keeps you from losing track of steps.",
98
+ parameters={
99
+ "type": "object",
100
+ "properties": {
101
+ "todos": {
102
+ "type": "array",
103
+ "items": {
104
+ "type": "object",
105
+ "properties": {
106
+ "content": {"type": "string", "description": "The task, in imperative form."},
107
+ "status": {
108
+ "type": "string",
109
+ "enum": ["pending", "in_progress", "completed"],
110
+ },
111
+ },
112
+ "required": ["content", "status"],
113
+ },
114
+ }
115
+ },
116
+ "required": ["todos"],
117
+ },
118
+ handler=_write_todos,
119
+ needs_permission=False,
120
+ plan_mode_safe=True,
121
+ read_only=False,
122
+ )