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
pcli/tui/screens/chat.py
ADDED
|
@@ -0,0 +1,2070 @@
|
|
|
1
|
+
"""Main chat screen: streams a conversation against the configured gateway,
|
|
2
|
+
dispatching tool calls through permissions + sandbox, persisting to a Session
|
|
3
|
+
after every turn."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import ClassVar
|
|
14
|
+
|
|
15
|
+
from textual import work
|
|
16
|
+
from textual.app import ComposeResult, SuspendNotSupported
|
|
17
|
+
from textual.binding import BindingType
|
|
18
|
+
from textual.containers import Vertical
|
|
19
|
+
from textual.screen import Screen
|
|
20
|
+
|
|
21
|
+
from pcli.agent.activity import ActivityTracker, format_subagent_activity
|
|
22
|
+
from pcli.agent.compaction import maybe_compact
|
|
23
|
+
from pcli.agent.context_pruning import extract_purpose, prune_old_tool_results
|
|
24
|
+
from pcli.agent.loop import AgentLoop, ToolResultEvent
|
|
25
|
+
from pcli.agent.prompt import PLAN_MODE_REINFORCEMENT, build_system_prompt
|
|
26
|
+
from pcli.agent.runtime import (
|
|
27
|
+
build_agent_runtime,
|
|
28
|
+
build_permission_manager,
|
|
29
|
+
effective_max_tool_iterations,
|
|
30
|
+
record_tool_invocation,
|
|
31
|
+
)
|
|
32
|
+
from pcli.browser.session import BrowserSession
|
|
33
|
+
from pcli.config.settings import Settings, get_settings, remove_config_keys, update_config_file
|
|
34
|
+
from pcli.cost.context import (
|
|
35
|
+
ContextLimitTable,
|
|
36
|
+
compute_max_response_tokens,
|
|
37
|
+
current_context_usage,
|
|
38
|
+
looks_like_context_ceiling,
|
|
39
|
+
set_model_context_limit,
|
|
40
|
+
)
|
|
41
|
+
from pcli.cost.pricing_table import ModelPricing, PricingTable
|
|
42
|
+
from pcli.cost.tracker import CostTracker, cost_budget_reason
|
|
43
|
+
from pcli.llm.client import GatewayClient
|
|
44
|
+
from pcli.llm.errors import GatewayError
|
|
45
|
+
from pcli.llm.models import ChatMessage, ToolCall, Usage
|
|
46
|
+
from pcli.memory.extraction import extract_memory
|
|
47
|
+
from pcli.memory.models import render_memory_list, render_memory_section
|
|
48
|
+
from pcli.memory.store import clear_memory, read_memory, remove_entry
|
|
49
|
+
from pcli.permissions.guardrails import update_guardrails_fs_allowed_roots, update_guardrails_limits
|
|
50
|
+
from pcli.permissions.manager import AskCallback
|
|
51
|
+
from pcli.sandbox.base import Sandbox, SandboxSecurityError
|
|
52
|
+
from pcli.sandbox.subprocess_backend import RestrictedSubprocessSandbox
|
|
53
|
+
from pcli.session.export import export_session
|
|
54
|
+
from pcli.session.models import Message, Session, ToolInvocation
|
|
55
|
+
from pcli.session.store import SessionStore
|
|
56
|
+
from pcli.tools.artifacts import SessionArtifactStore
|
|
57
|
+
from pcli.tools.base import AskQuestionCallback, ToolContext
|
|
58
|
+
from pcli.tools.registry import ToolRegistry
|
|
59
|
+
from pcli.tools.toolbox.manager import ToolboxDiscoveryError, ToolboxManager
|
|
60
|
+
from pcli.tui.screens.ask_question_modal import ask_question_via_modal
|
|
61
|
+
from pcli.tui.screens.permission_modal import ask_via_modal
|
|
62
|
+
from pcli.tui.screens.subagent_activity_modal import SubagentActivityModal
|
|
63
|
+
from pcli.tui.shell_passthrough import run_passthrough_command
|
|
64
|
+
from pcli.tui.widgets.chat_input import ChatInput
|
|
65
|
+
from pcli.tui.widgets.command_suggestions import CommandSuggestions
|
|
66
|
+
from pcli.tui.widgets.message_view import MessageView
|
|
67
|
+
from pcli.tui.widgets.status_bar import StatusBar
|
|
68
|
+
from pcli.tui.widgets.status_pane import StatusPane
|
|
69
|
+
|
|
70
|
+
# Used for local-api-mode sessions: always $0, regardless of pricing.toml or
|
|
71
|
+
# the builtin table — a local model's name could otherwise coincidentally
|
|
72
|
+
# match a paid pattern there (e.g. "llama-3*") and show a fake nonzero cost.
|
|
73
|
+
_FREE_PRICING_TABLE = PricingTable(entries={}, default=ModelPricing())
|
|
74
|
+
|
|
75
|
+
# How long a second Escape press has to land after the first to count as a
|
|
76
|
+
# "confirm cancel" double-press (see ChatScreen.action_cancel_turn).
|
|
77
|
+
_ESCAPE_DOUBLE_PRESS_WINDOW_S = 0.6
|
|
78
|
+
|
|
79
|
+
# Shared between /subagent and the Ctrl+G live-activity panel (action_
|
|
80
|
+
# show_subagent_activity) - both need to say the same thing when there's
|
|
81
|
+
# nothing to show.
|
|
82
|
+
_NO_SUBAGENT_RUNNING_MESSAGE = "No subagent is currently running."
|
|
83
|
+
|
|
84
|
+
# A real debugged case: the model's response got cut off by the token limit
|
|
85
|
+
# mid-task (e.g. "Let me implement X:" with no tool call following, because
|
|
86
|
+
# there was no room left to make one) - previously indistinguishable from a
|
|
87
|
+
# deliberate, complete stop, so the turn just silently ended with genuinely
|
|
88
|
+
# unfinished work and the user had to notice and type "continue" themselves.
|
|
89
|
+
# _run_one_turn now does that automatically (see TurnCompleteEvent.
|
|
90
|
+
# response_truncated), capped at this many consecutive attempts so a model
|
|
91
|
+
# that's stuck hitting the limit every single time doesn't loop forever.
|
|
92
|
+
_MAX_CONSECUTIVE_AUTO_CONTINUES = 3
|
|
93
|
+
_AUTO_CONTINUE_MESSAGE = "Continue."
|
|
94
|
+
|
|
95
|
+
# Static reference shown by /help — kept as one literal string (not built from
|
|
96
|
+
# the _handle_command dispatch table) since the wording needs full sentences,
|
|
97
|
+
# not just the bare command list already in ChatInput's placeholder text.
|
|
98
|
+
_HELP_TEXT = """\
|
|
99
|
+
# Commands
|
|
100
|
+
|
|
101
|
+
- **/help** — show this help.
|
|
102
|
+
- **/sessions** — browse, switch, or import previous sessions.
|
|
103
|
+
- **/export [path]** — export the current session (default: pcli's data \
|
|
104
|
+
directory).
|
|
105
|
+
- **/models [name]** — switch models. With no argument, lists models \
|
|
106
|
+
available from the configured gateway and lets you pick one.
|
|
107
|
+
- **/compact** — manually summarize the conversation so far to free up \
|
|
108
|
+
context space (also happens automatically as the context fills up).
|
|
109
|
+
- **/timeout [seconds]** — view or set the per-request timeout to the \
|
|
110
|
+
gateway.
|
|
111
|
+
- **/temperature [value|off]** — view or set the sampling temperature sent \
|
|
112
|
+
with each request; `off` clears it so no temperature field is sent at all \
|
|
113
|
+
(gateway/model default applies).
|
|
114
|
+
- **/budget [amount|off]** — view or set a hard cap on this session's total \
|
|
115
|
+
spend; the agent loop stops itself (with a clear notice) once spend reaches \
|
|
116
|
+
it, rather than only reporting cost after the fact. `off` clears it (no cap).
|
|
117
|
+
- **/context-limit [tokens]** — view or set the context window pcli assumes \
|
|
118
|
+
for the current model (used for the context-usage display and \
|
|
119
|
+
auto-compaction).
|
|
120
|
+
- **/max-tool-iterations [n]** — view or set how many tool-call round-trips \
|
|
121
|
+
a single turn can make before it's cut off (ignored — always uncapped — in \
|
|
122
|
+
local-api mode).
|
|
123
|
+
- **/artifact-threshold [chars]** — view or set the tool-output length \
|
|
124
|
+
beyond which results are archived out of the live conversation and \
|
|
125
|
+
retrieved later via fetch_artifact.
|
|
126
|
+
- **/max-tool-calls-per-turn [n]** — view or set the guardrail cap on tool \
|
|
127
|
+
calls within a single turn (0 = unlimited; ignored — always unlimited — in \
|
|
128
|
+
local-api mode).
|
|
129
|
+
- **/max-tool-calls-per-minute [n]** — view or set the guardrail cap on \
|
|
130
|
+
tool calls per minute, across the whole session (0 = unlimited; ignored \
|
|
131
|
+
— always unlimited — in local-api mode).
|
|
132
|
+
- **/allowed-roots [add|remove] [path]** — view or edit the filesystem \
|
|
133
|
+
guardrail's allowed_roots list (default: just the working directory). A \
|
|
134
|
+
path outside every allowed_roots entry is denied outright, before any \
|
|
135
|
+
permission prompt — this is how to widen what read_file/write_file/\
|
|
136
|
+
edit_file/list_dir/etc. can reach, not a prompt you can approve your way \
|
|
137
|
+
past.
|
|
138
|
+
- **/prune-tool-results [off|on|n]** — view, toggle, or set how many \
|
|
139
|
+
recent turns' tool results stay verbatim before older ones are shrunk to \
|
|
140
|
+
a short placeholder (archived, retrievable via fetch_artifact) to save \
|
|
141
|
+
context — no LLM call involved, runs every turn.
|
|
142
|
+
- **/max-response-tokens [off|on|margin]** — view, toggle, or set the \
|
|
143
|
+
dynamic max_tokens cap sent with each request, leaving `margin` tokens of \
|
|
144
|
+
headroom below the model's context limit so a single response can't \
|
|
145
|
+
consume the entire remaining window by itself.
|
|
146
|
+
- **/rename [name]** — view or set the current session's title (shown in \
|
|
147
|
+
/sessions).
|
|
148
|
+
- **/theme [name]** — view the available Textual themes (built-in and \
|
|
149
|
+
pcli's own vim-* ones) and which is active, or switch live and remember \
|
|
150
|
+
it for next time.
|
|
151
|
+
- **/plan** — enter plan mode: the agent can only use read-only/exploration \
|
|
152
|
+
tools (no writes, edits, or shell commands) until you exit.
|
|
153
|
+
- **/build** — exit plan mode, restoring full tool access.
|
|
154
|
+
- **/toolbox** — discover, list, or remove toolbox tools (CLI programs/\
|
|
155
|
+
scripts wrapped as callable tools): `/toolbox discover NAME [path]`, \
|
|
156
|
+
`/toolbox list`, `/toolbox remove NAME`.
|
|
157
|
+
- **/subagent** — show the currently-running subagent's task and its full \
|
|
158
|
+
tool-call history so far, plus any question it's currently waiting on an \
|
|
159
|
+
answer to. Nothing to show once it finishes (only its final text and \
|
|
160
|
+
tool-call count come back to the main conversation).
|
|
161
|
+
- **/memory [forget <id>|clear]** — view pcli's global, cross-session \
|
|
162
|
+
memory of you (nature of work, preferences, conversation style, common \
|
|
163
|
+
asks — injected into every session's system prompt), remove one entry, or \
|
|
164
|
+
clear it all. Grows automatically from what you explicitly ask to be \
|
|
165
|
+
remembered and, when a session gets long enough to auto-compact, from \
|
|
166
|
+
what pcli notices worth keeping.
|
|
167
|
+
|
|
168
|
+
# Shell passthrough
|
|
169
|
+
|
|
170
|
+
- **!command** — run a shell command directly (bypasses the model), showing \
|
|
171
|
+
its output.
|
|
172
|
+
- **!!command** — same, but hides the output.
|
|
173
|
+
- **!!!command** — hand off a real interactive terminal to the command \
|
|
174
|
+
(passwords, REPLs, editors, ssh — anything needing a real TTY).
|
|
175
|
+
|
|
176
|
+
# Input box
|
|
177
|
+
|
|
178
|
+
- **Enter** sends your message; **Ctrl+J** or **Alt+Enter** inserts a \
|
|
179
|
+
newline for a multi-line message. The box grows to fit what you type (up \
|
|
180
|
+
to 10 lines) and shrinks back down; a paste never grows it.
|
|
181
|
+
- **Up** / **Down** recall previously-sent messages, while the current \
|
|
182
|
+
draft has no newline in it.
|
|
183
|
+
- Typing **/** shows matching slash commands with a one-line description; \
|
|
184
|
+
**Up** / **Down** moves the highlight, **Tab** or **Enter** accepts it \
|
|
185
|
+
(without sending), and **Escape** dismisses the list.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
# Drives ChatInput's slash-command autocomplete (tui/widgets/command_
|
|
189
|
+
# suggestions.py) - a separate, terser list from _HELP_TEXT above (one-liners
|
|
190
|
+
# for a dropdown vs. full sentences for /help), so update both when adding,
|
|
191
|
+
# removing, or renaming a command. Order here is purely the order suggestions
|
|
192
|
+
# are listed in when several match (e.g. typing bare "/"), not otherwise
|
|
193
|
+
# significant - kept roughly matching _HELP_TEXT's own order for ease of
|
|
194
|
+
# cross-checking the two lists against each other.
|
|
195
|
+
_SLASH_COMMANDS: list[tuple[str, str]] = [
|
|
196
|
+
("help", "Show the full command reference."),
|
|
197
|
+
("sessions", "Browse, switch, or import previous sessions."),
|
|
198
|
+
("export", "Export the current session."),
|
|
199
|
+
("models", "Switch models, or list what's available."),
|
|
200
|
+
("compact", "Summarize the conversation so far to free up context."),
|
|
201
|
+
("timeout", "View or set the per-request gateway timeout."),
|
|
202
|
+
("temperature", "View or set the sampling temperature."),
|
|
203
|
+
("budget", "View or set a hard cap on this session's total spend."),
|
|
204
|
+
("context-limit", "View or set the assumed context window for this model."),
|
|
205
|
+
("max-tool-iterations", "View or set the per-turn tool-call iteration cap."),
|
|
206
|
+
("artifact-threshold", "View or set the tool-output archiving threshold."),
|
|
207
|
+
("max-tool-calls-per-turn", "View or set the guardrail cap on tool calls per turn."),
|
|
208
|
+
("max-tool-calls-per-minute", "View or set the guardrail cap on tool calls per minute."),
|
|
209
|
+
("allowed-roots", "View or edit which filesystem paths the agent can reach."),
|
|
210
|
+
("prune-tool-results", "View, toggle, or set old tool-result pruning."),
|
|
211
|
+
("max-response-tokens", "View, toggle, or set the dynamic response-length cap."),
|
|
212
|
+
("rename", "View or set the current session's title."),
|
|
213
|
+
("theme", "View or switch the TUI's color theme."),
|
|
214
|
+
("plan", "Enter plan mode (read-only tools only)."),
|
|
215
|
+
("build", "Exit plan mode, restoring full tool access."),
|
|
216
|
+
("toolbox", "Discover, list, or remove toolbox tools."),
|
|
217
|
+
("subagent", "Show the currently-running subagent's task and history."),
|
|
218
|
+
("memory", "View, forget, or clear pcli's memory of you."),
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
logger = logging.getLogger(__name__)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class ChatScreen(Screen):
|
|
225
|
+
# No custom quit binding needed: Textual's own App already binds ctrl+q
|
|
226
|
+
# to quit (with priority=True, so nothing here could override it even if
|
|
227
|
+
# we wanted to), and ctrl+c is reserved by Textual itself as a
|
|
228
|
+
# "press ctrl+q to quit" hint (App.action_help_quit) rather than quitting
|
|
229
|
+
# directly — deliberately, to avoid killing the app on a reflexive
|
|
230
|
+
# ctrl+c. A previous ("ctrl+c", "quit", "Quit") entry here never actually
|
|
231
|
+
# fired (confirmed: Textual's system-level binding for the same key
|
|
232
|
+
# always wins over a Screen-level one) and just misled anyone reading it.
|
|
233
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
234
|
+
("escape", "cancel_turn", "Cancel turn (press twice)"),
|
|
235
|
+
("ctrl+g", "show_subagent_activity", "Subagent activity"),
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
def __init__(
|
|
239
|
+
self,
|
|
240
|
+
settings: Settings | None = None,
|
|
241
|
+
*,
|
|
242
|
+
session: Session | None = None,
|
|
243
|
+
store: SessionStore | None = None,
|
|
244
|
+
) -> None:
|
|
245
|
+
super().__init__()
|
|
246
|
+
self._settings = settings or get_settings()
|
|
247
|
+
self._store = store or SessionStore()
|
|
248
|
+
self._client: GatewayClient | None = None
|
|
249
|
+
self._agent_loop: AgentLoop | None = None
|
|
250
|
+
self._permission_manager = build_permission_manager(self._settings)
|
|
251
|
+
self._sandbox: Sandbox | None = None
|
|
252
|
+
self._tool_registry: ToolRegistry | None = None
|
|
253
|
+
self._toolbox_manager: ToolboxManager | None = None
|
|
254
|
+
self._browser_session: BrowserSession | None = None
|
|
255
|
+
self._current_ask: AskCallback | None = None
|
|
256
|
+
self._current_ask_question: AskQuestionCallback | None = None
|
|
257
|
+
self._cwd = Path.cwd()
|
|
258
|
+
self._activity = ActivityTracker()
|
|
259
|
+
# Lets a message submitted while a turn is already running be
|
|
260
|
+
# queued and processed right after, instead of either being ignored
|
|
261
|
+
# or (the bug this fixes) silently cancelling the in-flight turn —
|
|
262
|
+
# see _stream_response/on_input_submitted.
|
|
263
|
+
self._turn_in_progress = False
|
|
264
|
+
# Raw text of user messages submitted while _turn_in_progress - kept
|
|
265
|
+
# out of session.messages (and, in the UI, shown via
|
|
266
|
+
# MessageView.add_queued_user_message rather than add_message) until
|
|
267
|
+
# the active turn actually finishes, so they can't land ahead of
|
|
268
|
+
# that turn's own assistant reply in the history, or hijack
|
|
269
|
+
# MessageView's in-flight streaming target - see on_chat_input_
|
|
270
|
+
# submitted/_stream_response for where this is populated/flushed.
|
|
271
|
+
self._queued_followups: list[str] = []
|
|
272
|
+
# Unrelated to the queue above - set only by the auto-continue-
|
|
273
|
+
# after-truncation path at the very end of a turn that's already
|
|
274
|
+
# finished (see _run_one_turn's response_truncated handling), purely
|
|
275
|
+
# to make _stream_response's loop run one more time.
|
|
276
|
+
self._has_queued_followup = False
|
|
277
|
+
self._last_escape_at = 0.0
|
|
278
|
+
self._plan_mode = False
|
|
279
|
+
self._consecutive_truncations = 0
|
|
280
|
+
"""How many turns in a row ended because the response was cut off by
|
|
281
|
+
the token limit (TurnCompleteEvent.response_truncated), not a
|
|
282
|
+
deliberate stop - see _run_one_turn's own handling. Reset whenever a
|
|
283
|
+
turn completes without truncation, or the user sends a real message
|
|
284
|
+
of their own (see on_chat_input_submitted)."""
|
|
285
|
+
|
|
286
|
+
if session is not None:
|
|
287
|
+
self._session = session
|
|
288
|
+
else:
|
|
289
|
+
self._session = self._store.new_session(
|
|
290
|
+
model=self._settings.default_model,
|
|
291
|
+
gateway_base_url=self._settings.gateway_base_url,
|
|
292
|
+
working_dir=str(self._cwd),
|
|
293
|
+
)
|
|
294
|
+
extra_sections = []
|
|
295
|
+
if self._settings.memory_enabled:
|
|
296
|
+
memory_section = render_memory_section(read_memory().entries)
|
|
297
|
+
if memory_section:
|
|
298
|
+
extra_sections.append(memory_section)
|
|
299
|
+
self._session.messages.append(
|
|
300
|
+
Message(
|
|
301
|
+
role="system",
|
|
302
|
+
content=build_system_prompt(extra_sections=extra_sections or None),
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
pricing_table = _FREE_PRICING_TABLE if self._settings.is_local_api() else None
|
|
307
|
+
self._cost_tracker = CostTracker(self._session, pricing_table=pricing_table)
|
|
308
|
+
self._context_limit_table = ContextLimitTable.load()
|
|
309
|
+
# Set when the startup probe in _maybe_detect_context_limit comes up
|
|
310
|
+
# empty - worth one retry after the model has actually produced a
|
|
311
|
+
# response, since a local gateway (LM Studio, in particular) only
|
|
312
|
+
# reports a model's real loaded context size once it's actually
|
|
313
|
+
# resident, and JIT-loads on first inference. Consumed (reset False)
|
|
314
|
+
# by the one retry attempt in _run_one_turn, so a backend that
|
|
315
|
+
# genuinely can't be probed (a hosted API) doesn't get re-hit every
|
|
316
|
+
# turn for the rest of the session.
|
|
317
|
+
self._context_limit_retry_pending = False
|
|
318
|
+
self._artifact_store = SessionArtifactStore(self._store, self._session.id)
|
|
319
|
+
|
|
320
|
+
def compose(self) -> ComposeResult:
|
|
321
|
+
with Vertical():
|
|
322
|
+
yield StatusPane(id="status-pane")
|
|
323
|
+
yield MessageView(id="message-view")
|
|
324
|
+
yield StatusBar(id="status-bar")
|
|
325
|
+
yield CommandSuggestions(id="command-suggestions")
|
|
326
|
+
yield ChatInput(
|
|
327
|
+
placeholder="Ask pcli... (/help for all commands — Enter to send, "
|
|
328
|
+
"Ctrl+J for a newline)",
|
|
329
|
+
id="input-box",
|
|
330
|
+
commands=_SLASH_COMMANDS,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def on_chat_input_suggestions_changed(self, event: ChatInput.SuggestionsChanged) -> None:
|
|
334
|
+
self.query_one(CommandSuggestions).update_suggestions(event.matches, event.index)
|
|
335
|
+
|
|
336
|
+
def _refresh_cost_display(self, status_bar: StatusBar) -> None:
|
|
337
|
+
status_bar.session_cost_usd = self._session.cost.session_total_usd
|
|
338
|
+
status_bar.total_tokens = self._session.cost.total_tokens
|
|
339
|
+
|
|
340
|
+
def _refresh_context_display(self, status_bar: StatusBar, usage: Usage) -> None:
|
|
341
|
+
# Deliberately driven by the parent loop's OWN usage events only (never
|
|
342
|
+
# a subagent's, even though subagent usage also lands in
|
|
343
|
+
# session.cost.turns for billing) — a subagent's isolated, much
|
|
344
|
+
# smaller context isn't the conversation's actual context size.
|
|
345
|
+
model = self._settings.default_model or self._session.model
|
|
346
|
+
status_bar.context_used_tokens = usage.total_tokens
|
|
347
|
+
status_bar.context_limit_tokens = self._context_limit_table.lookup(model)
|
|
348
|
+
|
|
349
|
+
def _refresh_todo_pane(self) -> None:
|
|
350
|
+
self.query_one(StatusPane).todos = list(self._session.todos)
|
|
351
|
+
|
|
352
|
+
def _effective_max_tool_iterations(self) -> int | None:
|
|
353
|
+
return effective_max_tool_iterations(self._settings)
|
|
354
|
+
|
|
355
|
+
def _on_activity_changed(self) -> None:
|
|
356
|
+
status_bar = self.query_one(StatusBar)
|
|
357
|
+
sub = self._activity.subagent
|
|
358
|
+
status_bar.subagent_task = sub.task if sub else None
|
|
359
|
+
status_bar.subagent_tool_calls = sub.tool_calls if sub else 0
|
|
360
|
+
status_bar.subagent_last_tool = sub.last_tool if sub else None
|
|
361
|
+
|
|
362
|
+
async def on_mount(self) -> None:
|
|
363
|
+
# Cheap housekeeping: clears out empty sessions left over from a
|
|
364
|
+
# previous run (see SessionStore.prune_empty_sessions) so /sessions
|
|
365
|
+
# doesn't accumulate clutter from every launch that never sent a
|
|
366
|
+
# message. Excludes the just-started/resumed session itself, which
|
|
367
|
+
# may still legitimately have zero messages at this exact point.
|
|
368
|
+
self._store.prune_empty_sessions(exclude_session_ids=[self._session.id])
|
|
369
|
+
self.query_one(ChatInput).focus()
|
|
370
|
+
status_bar = self.query_one(StatusBar)
|
|
371
|
+
status_bar.model = self._session.model or self._settings.default_model
|
|
372
|
+
self._refresh_cost_display(status_bar)
|
|
373
|
+
context_usage = current_context_usage(self._session, limit_table=self._context_limit_table)
|
|
374
|
+
status_bar.context_used_tokens = context_usage.used_tokens
|
|
375
|
+
status_bar.context_limit_tokens = context_usage.limit_tokens
|
|
376
|
+
self._activity.subscribe(self._on_activity_changed)
|
|
377
|
+
self._refresh_todo_pane()
|
|
378
|
+
|
|
379
|
+
self._replay_message_history()
|
|
380
|
+
|
|
381
|
+
message_view = self.query_one(MessageView)
|
|
382
|
+
if self._session.todos:
|
|
383
|
+
from pcli.tools.builtin.todo_tool import render_todos
|
|
384
|
+
|
|
385
|
+
message_view.add_message(
|
|
386
|
+
"system", f"Resuming with existing todos:\n{render_todos(self._session.todos)}"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
if self._session.decisions:
|
|
390
|
+
from pcli.tools.builtin.decision_tool import render_decisions
|
|
391
|
+
|
|
392
|
+
message_view.add_message(
|
|
393
|
+
"system",
|
|
394
|
+
f"Resuming with {len(self._session.decisions)} recorded decision(s):\n"
|
|
395
|
+
f"{render_decisions(self._session.decisions)}",
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
if not self._settings.is_configured():
|
|
399
|
+
message_view.add_message(
|
|
400
|
+
"system",
|
|
401
|
+
"Gateway not configured. Set `PCLI_GATEWAY_URL` (and `PCLI_GATEWAY_API_KEY` if "
|
|
402
|
+
"your gateway requires auth) or edit the config file, then restart pcli.",
|
|
403
|
+
)
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
if self._settings.is_local_api():
|
|
407
|
+
message_view.add_message(
|
|
408
|
+
"system",
|
|
409
|
+
"Local-API mode active for this gateway: max_tool_iterations and the "
|
|
410
|
+
"guardrails' max_tool_calls_per_turn/per_minute are uncapped, and cost is "
|
|
411
|
+
"forced to $0.",
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
runtime = await build_agent_runtime(self._settings, self._cwd)
|
|
416
|
+
except Exception as exc: # noqa: BLE001 - surface startup failure, don't crash
|
|
417
|
+
message_view.add_message("system", f"Startup failed: {exc}")
|
|
418
|
+
return
|
|
419
|
+
self._sandbox = runtime.sandbox
|
|
420
|
+
status_bar.sandbox_backend = self._sandbox.name
|
|
421
|
+
self._tool_registry = runtime.tool_registry
|
|
422
|
+
self._toolbox_manager = runtime.toolbox_manager
|
|
423
|
+
self._browser_session = runtime.browser_session
|
|
424
|
+
if runtime.toolbox_tools_loaded:
|
|
425
|
+
message_view.add_message(
|
|
426
|
+
"system",
|
|
427
|
+
f"Loaded {runtime.toolbox_tools_loaded} previously-discovered toolbox tool(s).",
|
|
428
|
+
)
|
|
429
|
+
if runtime.agent_tools_loaded:
|
|
430
|
+
message_view.add_message(
|
|
431
|
+
"system",
|
|
432
|
+
f"Loaded {runtime.agent_tools_loaded} previously-registered agent tool(s).",
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
self._client = runtime.client
|
|
436
|
+
await self._maybe_detect_context_limit(message_view)
|
|
437
|
+
self._agent_loop = AgentLoop(
|
|
438
|
+
self._client,
|
|
439
|
+
model=self._settings.default_model or None,
|
|
440
|
+
tool_registry=self._tool_registry,
|
|
441
|
+
permission_manager=self._permission_manager,
|
|
442
|
+
tool_context_factory=self._make_tool_context,
|
|
443
|
+
max_tool_iterations=self._effective_max_tool_iterations(),
|
|
444
|
+
artifact_threshold_chars=self._settings.artifact_threshold_chars,
|
|
445
|
+
temperature=self._settings.default_temperature,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
async def _maybe_detect_context_limit(
|
|
449
|
+
self, message_view: MessageView, *, is_retry: bool = False
|
|
450
|
+
) -> None:
|
|
451
|
+
"""Best-effort: if pcli has no context-window entry for this model
|
|
452
|
+
yet, or the entry it has came from a previous auto-detect run
|
|
453
|
+
(ContextLimitTable.should_attempt_detection — a manual
|
|
454
|
+
/context-limit correction or a built-in default is never re-probed
|
|
455
|
+
or overwritten, only a prior auto-detected value), ask the gateway
|
|
456
|
+
directly (GatewayClient.detect_context_limit / cost/context_detect.py).
|
|
457
|
+
Several backends (LM Studio, Ollama, a LiteLLM proxy, a raw
|
|
458
|
+
llama.cpp server, and — for free, via the standard /models
|
|
459
|
+
response — OpenRouter/vLLM) expose this; hosted-only gateways
|
|
460
|
+
(OpenAI, Anthropic, ...) don't, in which case the notice below
|
|
461
|
+
points at /context-limit instead. Re-probing an already-auto-detected
|
|
462
|
+
model on every startup matters for local gateways specifically: LM
|
|
463
|
+
Studio/Ollama let you reload the same model with a different context
|
|
464
|
+
length, so a value pcli auto-detected once can silently go stale in
|
|
465
|
+
a way a hosted API's fixed limit or a human's manual override never
|
|
466
|
+
does — see a real debugged case in cost/context.py's
|
|
467
|
+
looks_like_context_ceiling docstring. Wrapped defensively so a probe
|
|
468
|
+
failure/timeout never blocks startup — this is a nice-to-have, not
|
|
469
|
+
a requirement for the rest of on_mount to complete.
|
|
470
|
+
|
|
471
|
+
`is_retry=True` is the one-shot retry from _run_one_turn's "usage"
|
|
472
|
+
handling, after the model has actually produced a response - a
|
|
473
|
+
gateway that JIT-loads (LM Studio) may not report a model's real
|
|
474
|
+
loaded context size until then, even though should_attempt_detection
|
|
475
|
+
said this model was worth probing. It only changes whether a second
|
|
476
|
+
failure re-arms _context_limit_retry_pending: the initial call sets
|
|
477
|
+
it so a retry gets scheduled, but the retry itself must not, or a
|
|
478
|
+
backend that can never be probed (a hosted API) would get re-hit
|
|
479
|
+
every single turn for the rest of the session instead of once."""
|
|
480
|
+
if not self._settings.context_limit_auto_detect_enabled:
|
|
481
|
+
return
|
|
482
|
+
model = self._settings.default_model or self._session.model
|
|
483
|
+
if not model or not self._context_limit_table.should_attempt_detection(model):
|
|
484
|
+
return
|
|
485
|
+
try:
|
|
486
|
+
limit = await self._client.detect_context_limit(model)
|
|
487
|
+
except Exception: # noqa: BLE001 - never let a probe failure block startup
|
|
488
|
+
limit = None
|
|
489
|
+
|
|
490
|
+
if limit:
|
|
491
|
+
set_model_context_limit(model, limit, auto_detected=True)
|
|
492
|
+
self._context_limit_table = ContextLimitTable.load()
|
|
493
|
+
self.query_one(StatusBar).context_limit_tokens = self._context_limit_table.lookup(model)
|
|
494
|
+
message_view.add_message(
|
|
495
|
+
"system", f"Auto-detected context limit for '{model}': {limit:,} tokens."
|
|
496
|
+
)
|
|
497
|
+
else:
|
|
498
|
+
if not is_retry:
|
|
499
|
+
self._context_limit_retry_pending = True
|
|
500
|
+
assumed = self._context_limit_table.lookup(model)
|
|
501
|
+
message_view.add_message(
|
|
502
|
+
"system",
|
|
503
|
+
f"Couldn't auto-detect a context limit for '{model}' — pcli is assuming "
|
|
504
|
+
f"{assumed:,} tokens. If that's wrong, set it with /context-limit <tokens>.",
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
def _effective_tool_registry(self) -> ToolRegistry | None:
|
|
508
|
+
"""self._tool_registry filtered to plan_mode_safe tools while plan
|
|
509
|
+
mode is active, recomputed fresh each time (rather than cached)
|
|
510
|
+
since self._tool_registry itself can grow after startup, e.g. via
|
|
511
|
+
register_toolbox_tool/register_agent_tool)."""
|
|
512
|
+
if self._tool_registry is None:
|
|
513
|
+
return None
|
|
514
|
+
if self._plan_mode:
|
|
515
|
+
return self._tool_registry.filtered(lambda t: t.plan_mode_safe)
|
|
516
|
+
return self._tool_registry
|
|
517
|
+
|
|
518
|
+
def _make_tool_context(self) -> ToolContext:
|
|
519
|
+
assert self._sandbox is not None
|
|
520
|
+
return ToolContext(
|
|
521
|
+
sandbox=self._sandbox,
|
|
522
|
+
guardrails=self._permission_manager.guardrails,
|
|
523
|
+
cwd=self._cwd,
|
|
524
|
+
gateway_client=self._client,
|
|
525
|
+
model=self._settings.default_model or None,
|
|
526
|
+
tool_registry=self._effective_tool_registry(),
|
|
527
|
+
permission_manager=self._permission_manager,
|
|
528
|
+
ask=self._current_ask,
|
|
529
|
+
ask_question=self._current_ask_question,
|
|
530
|
+
brave_search_api_key=self._settings.brave_search_api_key,
|
|
531
|
+
memory_enabled=self._settings.memory_enabled,
|
|
532
|
+
memory_max_entries=self._settings.memory_max_entries,
|
|
533
|
+
max_tool_iterations=self._effective_max_tool_iterations(),
|
|
534
|
+
subagent_max_iterations=self._settings.subagent_max_iterations,
|
|
535
|
+
max_response_tokens=self._agent_loop.max_response_tokens if self._agent_loop else None,
|
|
536
|
+
temperature=self._agent_loop.temperature if self._agent_loop else None,
|
|
537
|
+
session=self._session,
|
|
538
|
+
max_session_cost_usd=self._settings.max_session_cost_usd,
|
|
539
|
+
artifact_store=self._artifact_store,
|
|
540
|
+
activity=self._activity,
|
|
541
|
+
toolbox_manager=self._toolbox_manager,
|
|
542
|
+
plan_mode=self._plan_mode,
|
|
543
|
+
browser_session=self._browser_session,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
async def on_unmount(self) -> None:
|
|
547
|
+
if self._client is not None:
|
|
548
|
+
await self._client.aclose()
|
|
549
|
+
if isinstance(self._sandbox, RestrictedSubprocessSandbox):
|
|
550
|
+
await self._sandbox.kill_all_background_jobs()
|
|
551
|
+
if self._browser_session is not None:
|
|
552
|
+
await self._browser_session.close()
|
|
553
|
+
|
|
554
|
+
def action_cancel_turn(self) -> None:
|
|
555
|
+
"""Esc+Esc: cancels the in-flight turn (the streaming reply and/or
|
|
556
|
+
whatever tool is currently executing as part of it). A no-op with no
|
|
557
|
+
turn running, so idly pressing Escape does nothing. Requires two
|
|
558
|
+
presses within _ESCAPE_DOUBLE_PRESS_WINDOW_S so a single reflexive
|
|
559
|
+
Escape (e.g. dismissing a thought, or a stray keypress) can't
|
|
560
|
+
accidentally kill real work — the first press just shows a hint."""
|
|
561
|
+
if not self._turn_in_progress:
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
now = time.monotonic()
|
|
565
|
+
if now - self._last_escape_at <= _ESCAPE_DOUBLE_PRESS_WINDOW_S:
|
|
566
|
+
self._last_escape_at = 0.0
|
|
567
|
+
message_view = self.query_one(MessageView)
|
|
568
|
+
# _stream_response's while-loop (see its docstring) is what
|
|
569
|
+
# cancel_group actually kills. If a follow-up was queued right
|
|
570
|
+
# before this, _has_queued_followup would stay stuck True with
|
|
571
|
+
# no live loop left to consult it, and a LATER, unrelated turn's
|
|
572
|
+
# loop-check would spuriously run an extra empty-input
|
|
573
|
+
# _run_one_turn() — clearing it here is required, not optional.
|
|
574
|
+
dropped_count = len(self._queued_followups)
|
|
575
|
+
self._queued_followups = []
|
|
576
|
+
self._has_queued_followup = False
|
|
577
|
+
self.workers.cancel_group(self, "agent-turn")
|
|
578
|
+
note = "Turn cancelled."
|
|
579
|
+
if dropped_count:
|
|
580
|
+
plural = "s" if dropped_count != 1 else ""
|
|
581
|
+
note += f" {dropped_count} queued follow-up message{plural} not sent."
|
|
582
|
+
message_view.add_message("system", note)
|
|
583
|
+
else:
|
|
584
|
+
self._last_escape_at = now
|
|
585
|
+
# A toast, not a transcript message - purely instructional, no
|
|
586
|
+
# value in a permanent scrollback record (unlike "Turn
|
|
587
|
+
# cancelled." above, a real conversational event).
|
|
588
|
+
self.notify("Press Esc again to cancel the current turn.")
|
|
589
|
+
|
|
590
|
+
def on_chat_input_submitted(self, event: ChatInput.Submitted) -> None:
|
|
591
|
+
text = event.value.strip()
|
|
592
|
+
text = event.chat_input.consume_pending_paste(text)
|
|
593
|
+
if not text:
|
|
594
|
+
return
|
|
595
|
+
event.chat_input.text = ""
|
|
596
|
+
event.chat_input.add_to_history(text)
|
|
597
|
+
|
|
598
|
+
if text.startswith("!!!"):
|
|
599
|
+
self._run_interactive_shell(text[3:].strip())
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
if text.startswith("!"):
|
|
603
|
+
self._run_shell_passthrough(text)
|
|
604
|
+
return
|
|
605
|
+
|
|
606
|
+
if text.startswith("/"):
|
|
607
|
+
self._handle_command(text)
|
|
608
|
+
return
|
|
609
|
+
|
|
610
|
+
if self._agent_loop is None:
|
|
611
|
+
return
|
|
612
|
+
message_view = self.query_one(MessageView)
|
|
613
|
+
self._consecutive_truncations = 0 # a real message means a fresh direction
|
|
614
|
+
if self._turn_in_progress:
|
|
615
|
+
# Queue it rather than starting a second _stream_response worker
|
|
616
|
+
# (which, on the same exclusive group, would cancel the one
|
|
617
|
+
# already running instead of running alongside or after it).
|
|
618
|
+
# Deliberately NOT appended to session.messages and NOT shown via
|
|
619
|
+
# add_message here - either would happen out of order relative
|
|
620
|
+
# to the in-flight turn's own assistant reply (session.messages
|
|
621
|
+
# would see this new user message before that reply, since
|
|
622
|
+
# turn_complete hasn't fired yet) and add_message would hijack
|
|
623
|
+
# MessageView's in-flight streaming target mid-response. Shown
|
|
624
|
+
# via the non-disruptive add_queued_user_message instead;
|
|
625
|
+
# _stream_response folds it into session.messages once the
|
|
626
|
+
# active turn actually finishes.
|
|
627
|
+
self._queued_followups.append(text)
|
|
628
|
+
message_view.add_queued_user_message(text)
|
|
629
|
+
else:
|
|
630
|
+
self._session.messages.append(Message(role="user", content=text))
|
|
631
|
+
message_view.add_message("user", text)
|
|
632
|
+
self._stream_response()
|
|
633
|
+
|
|
634
|
+
def _run_interactive_shell(self, command: str) -> None:
|
|
635
|
+
"""Runs `!!!cmd` with a real terminal handed to it (passwords,
|
|
636
|
+
REPLs, editors, ssh — anything that needs an actual TTY, which the
|
|
637
|
+
pipe-based !/!! passthrough below can't provide since Textual
|
|
638
|
+
already owns pcli's own stdin). Deliberately a plain sync method,
|
|
639
|
+
not a @work worker: App.suspend() is itself a synchronous context
|
|
640
|
+
manager (Textual stops reading/writing the terminal entirely for
|
|
641
|
+
its duration), so there's nothing useful the event loop could do
|
|
642
|
+
concurrently anyway — blocking here is the correct behavior, not a
|
|
643
|
+
workaround."""
|
|
644
|
+
message_view = self.query_one(MessageView)
|
|
645
|
+
if not command:
|
|
646
|
+
# A toast, not a transcript message - a usage hint, same "glance
|
|
647
|
+
# and maybe retype" reasoning as /timeout above.
|
|
648
|
+
self.notify(
|
|
649
|
+
"Usage: !!!<command> to run a command with a real interactive terminal",
|
|
650
|
+
severity="warning",
|
|
651
|
+
)
|
|
652
|
+
return
|
|
653
|
+
|
|
654
|
+
message_view.add_message("shell", f"→ Handing off terminal to: {command}")
|
|
655
|
+
try:
|
|
656
|
+
with self.app.suspend():
|
|
657
|
+
exit_code = subprocess.run(
|
|
658
|
+
command, shell=True, cwd=str(self._cwd), check=False
|
|
659
|
+
).returncode
|
|
660
|
+
except SuspendNotSupported:
|
|
661
|
+
self.notify(
|
|
662
|
+
"Interactive shell handoff isn't supported in this terminal environment.",
|
|
663
|
+
severity="warning",
|
|
664
|
+
)
|
|
665
|
+
return
|
|
666
|
+
message_view.add_message(
|
|
667
|
+
"shell", f"$ {command} (ran interactively)\n[exit_code={exit_code}]"
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
@work(exclusive=False)
|
|
671
|
+
async def _run_shell_passthrough(self, raw: str) -> None:
|
|
672
|
+
"""Runs a raw shell command the user typed directly (`!cmd`, or
|
|
673
|
+
`!!cmd` to hide the result). Deliberately bypasses the LLM, the
|
|
674
|
+
sandbox, permissions, and session/artifact recording entirely — this
|
|
675
|
+
never touches self._session or gets sent to the model in any way."""
|
|
676
|
+
message_view = self.query_one(MessageView)
|
|
677
|
+
quiet = raw.startswith("!!")
|
|
678
|
+
command = raw[2:].strip() if quiet else raw[1:].strip()
|
|
679
|
+
if not command:
|
|
680
|
+
# A toast, not a transcript message - same "glance and maybe
|
|
681
|
+
# retype" reasoning as /timeout above.
|
|
682
|
+
self.notify(
|
|
683
|
+
"Usage: !<command> to run a shell command (!!<command> to hide the result)",
|
|
684
|
+
severity="warning",
|
|
685
|
+
)
|
|
686
|
+
return
|
|
687
|
+
|
|
688
|
+
result = await run_passthrough_command(command, cwd=self._cwd)
|
|
689
|
+
|
|
690
|
+
if quiet:
|
|
691
|
+
message_view.add_message("shell", f"$ {command}\n(output hidden)")
|
|
692
|
+
return
|
|
693
|
+
|
|
694
|
+
output = result.stdout
|
|
695
|
+
if result.stderr:
|
|
696
|
+
output += f"\n--- stderr ---\n{result.stderr}"
|
|
697
|
+
footer = f"\n[exit_code={result.exit_code}]"
|
|
698
|
+
if result.timed_out:
|
|
699
|
+
footer += " (timed out)"
|
|
700
|
+
message_view.add_message("shell", f"$ {command}\n{output}{footer}")
|
|
701
|
+
|
|
702
|
+
def _handle_command(self, text: str) -> None:
|
|
703
|
+
command, _, rest = text[1:].partition(" ")
|
|
704
|
+
rest = rest.strip()
|
|
705
|
+
|
|
706
|
+
if command == "sessions":
|
|
707
|
+
from pcli.tui.screens.sessions import SessionListScreen
|
|
708
|
+
|
|
709
|
+
self.app.push_screen(
|
|
710
|
+
SessionListScreen(self._store, exclude_session_id=self._session.id)
|
|
711
|
+
)
|
|
712
|
+
elif command == "export":
|
|
713
|
+
self._export_current(rest or None)
|
|
714
|
+
elif command == "toolbox":
|
|
715
|
+
self._handle_toolbox_command(rest)
|
|
716
|
+
elif command == "models":
|
|
717
|
+
self._handle_models_command(rest or None)
|
|
718
|
+
elif command == "compact":
|
|
719
|
+
self._manual_compact()
|
|
720
|
+
elif command == "timeout":
|
|
721
|
+
self._handle_timeout_command(rest or None)
|
|
722
|
+
elif command == "temperature":
|
|
723
|
+
self._handle_temperature_command(rest or None)
|
|
724
|
+
elif command == "budget":
|
|
725
|
+
self._handle_budget_command(rest or None)
|
|
726
|
+
elif command == "context-limit":
|
|
727
|
+
self._handle_context_limit_command(rest or None)
|
|
728
|
+
elif command == "max-tool-iterations":
|
|
729
|
+
self._handle_max_tool_iterations_command(rest or None)
|
|
730
|
+
elif command == "artifact-threshold":
|
|
731
|
+
self._handle_artifact_threshold_command(rest or None)
|
|
732
|
+
elif command == "max-tool-calls-per-turn":
|
|
733
|
+
self._handle_guardrail_rate_limit_command(
|
|
734
|
+
rest or None,
|
|
735
|
+
attr_name="max_tool_calls_per_turn",
|
|
736
|
+
config_key="max_tool_calls_per_turn",
|
|
737
|
+
command_name="max-tool-calls-per-turn",
|
|
738
|
+
window="turn",
|
|
739
|
+
)
|
|
740
|
+
elif command == "max-tool-calls-per-minute":
|
|
741
|
+
self._handle_guardrail_rate_limit_command(
|
|
742
|
+
rest or None,
|
|
743
|
+
attr_name="max_tool_calls_per_minute",
|
|
744
|
+
config_key="max_tool_calls_per_minute",
|
|
745
|
+
command_name="max-tool-calls-per-minute",
|
|
746
|
+
window="tool call",
|
|
747
|
+
)
|
|
748
|
+
elif command == "allowed-roots":
|
|
749
|
+
self._handle_allowed_roots_command(rest)
|
|
750
|
+
elif command == "prune-tool-results":
|
|
751
|
+
self._handle_prune_tool_results_command(rest or None)
|
|
752
|
+
elif command == "subagent":
|
|
753
|
+
self._handle_subagent_command()
|
|
754
|
+
elif command == "memory":
|
|
755
|
+
self._handle_memory_command(rest)
|
|
756
|
+
elif command == "max-response-tokens":
|
|
757
|
+
self._handle_max_response_tokens_command(rest or None)
|
|
758
|
+
elif command == "rename":
|
|
759
|
+
self._handle_rename_command(rest or None)
|
|
760
|
+
elif command == "theme":
|
|
761
|
+
self._handle_theme_command(rest or None)
|
|
762
|
+
elif command == "plan":
|
|
763
|
+
self._set_plan_mode(True)
|
|
764
|
+
elif command == "build":
|
|
765
|
+
self._set_plan_mode(False)
|
|
766
|
+
elif command == "help":
|
|
767
|
+
self._handle_help_command()
|
|
768
|
+
else:
|
|
769
|
+
# A toast, not a transcript message - a mistyped command isn't
|
|
770
|
+
# worth a permanent scrollback record.
|
|
771
|
+
self.notify(f"Unknown command: /{command}", severity="warning")
|
|
772
|
+
|
|
773
|
+
def _handle_timeout_command(self, arg: str | None) -> None:
|
|
774
|
+
"""`/timeout [seconds]` — GatewayClient reads request_timeout_s fresh
|
|
775
|
+
on every request (see llm/client.py's per-request timeout override),
|
|
776
|
+
so changing it here takes effect on the very next gateway call, no
|
|
777
|
+
restart needed. Persisted the same way /models persists a
|
|
778
|
+
selection, so it's remembered next time too. A toast, not a
|
|
779
|
+
transcript message - see the "Split system notices" design note:
|
|
780
|
+
this is exactly the "glance and maybe retype" class of confirmation,
|
|
781
|
+
trivially re-checked by running /timeout again with no argument."""
|
|
782
|
+
if not arg:
|
|
783
|
+
current = self._settings.request_timeout_s
|
|
784
|
+
effective = self._settings.effective_request_timeout_s
|
|
785
|
+
note = f" (effective: {effective:g}s — floored for local-api)" if effective != current else ""
|
|
786
|
+
self.notify(f"request_timeout_s is currently {current:g}s{note}. Usage: /timeout <seconds>")
|
|
787
|
+
return
|
|
788
|
+
|
|
789
|
+
try:
|
|
790
|
+
seconds = float(arg)
|
|
791
|
+
except ValueError:
|
|
792
|
+
self.notify(f"'{arg}' isn't a valid number of seconds.", severity="warning")
|
|
793
|
+
return
|
|
794
|
+
if seconds <= 0:
|
|
795
|
+
self.notify("request_timeout_s must be greater than 0.", severity="warning")
|
|
796
|
+
return
|
|
797
|
+
|
|
798
|
+
self._settings.request_timeout_s = seconds
|
|
799
|
+
update_config_file(request_timeout_s=seconds)
|
|
800
|
+
self.notify(f"request_timeout_s set to {seconds:g}s — takes effect on the next gateway request.")
|
|
801
|
+
|
|
802
|
+
def _handle_temperature_command(self, arg: str | None) -> None:
|
|
803
|
+
"""`/temperature [value|off]` — sets the sampling temperature sent
|
|
804
|
+
with each request (AgentLoop.set_temperature -> GatewayClient.
|
|
805
|
+
chat_stream's temperature param), taking effect on the very next
|
|
806
|
+
turn. `off` clears it back to "unset" (no temperature field sent at
|
|
807
|
+
all, so the gateway/model's own default applies) — this needs
|
|
808
|
+
remove_config_keys, not update_config_file, since update_config_file
|
|
809
|
+
deliberately skips writing a None value rather than persisting the
|
|
810
|
+
removal. A toast, not a transcript message - same "glance and
|
|
811
|
+
maybe retype" reasoning as /timeout above."""
|
|
812
|
+
if not arg:
|
|
813
|
+
current = self._settings.default_temperature
|
|
814
|
+
text = f"{current:g}" if current is not None else "unset (gateway/model default)"
|
|
815
|
+
self.notify(f"default_temperature is currently {text}. Usage: /temperature <value>|off")
|
|
816
|
+
return
|
|
817
|
+
|
|
818
|
+
if arg == "off":
|
|
819
|
+
self._settings.default_temperature = None
|
|
820
|
+
remove_config_keys("default_temperature")
|
|
821
|
+
if self._agent_loop is not None:
|
|
822
|
+
self._agent_loop.set_temperature(None)
|
|
823
|
+
self.notify("default_temperature cleared — gateway/model default applies.")
|
|
824
|
+
return
|
|
825
|
+
|
|
826
|
+
try:
|
|
827
|
+
value = float(arg)
|
|
828
|
+
except ValueError:
|
|
829
|
+
self.notify(f"'{arg}' isn't a valid number, or 'off'.", severity="warning")
|
|
830
|
+
return
|
|
831
|
+
if value < 0:
|
|
832
|
+
self.notify("Temperature must be 0 or greater.", severity="warning")
|
|
833
|
+
return
|
|
834
|
+
|
|
835
|
+
self._settings.default_temperature = value
|
|
836
|
+
update_config_file(default_temperature=value)
|
|
837
|
+
if self._agent_loop is not None:
|
|
838
|
+
self._agent_loop.set_temperature(value)
|
|
839
|
+
self.notify(f"default_temperature set to {value:g} — takes effect on the next turn.")
|
|
840
|
+
|
|
841
|
+
def _handle_budget_command(self, arg: str | None) -> None:
|
|
842
|
+
"""`/budget [amount|off]` — views or sets Settings.max_session_cost_usd,
|
|
843
|
+
a hard cap on this session's total spend (agent/loop.py's
|
|
844
|
+
AgentLoop.run_turn consults it, via cost/tracker.py's
|
|
845
|
+
cost_budget_reason, at the top of every internal LLM round-trip - see
|
|
846
|
+
that function's own docstring). Same persistence shape as /temperature:
|
|
847
|
+
`off` needs remove_config_keys, not update_config_file, since the
|
|
848
|
+
latter deliberately skips writing a None value rather than persisting
|
|
849
|
+
a removal. Takes effect on the very next round-trip, no restart - the
|
|
850
|
+
budget_check closure reads self._settings fresh each time it's
|
|
851
|
+
called. A toast, not a transcript message - same "glance and maybe
|
|
852
|
+
retype" reasoning as /timeout above."""
|
|
853
|
+
spent = self._session.cost.session_total_usd
|
|
854
|
+
if not arg:
|
|
855
|
+
current = self._settings.max_session_cost_usd
|
|
856
|
+
text = f"${current:.2f}" if current is not None else "unset (no cap)"
|
|
857
|
+
self.notify(
|
|
858
|
+
f"max_session_cost_usd is currently {text} (spent so far: ${spent:.4f}). "
|
|
859
|
+
"Usage: /budget <amount>|off"
|
|
860
|
+
)
|
|
861
|
+
return
|
|
862
|
+
|
|
863
|
+
if arg == "off":
|
|
864
|
+
self._settings.max_session_cost_usd = None
|
|
865
|
+
remove_config_keys("max_session_cost_usd")
|
|
866
|
+
self.notify("max_session_cost_usd cleared — no cap.")
|
|
867
|
+
return
|
|
868
|
+
|
|
869
|
+
try:
|
|
870
|
+
value = float(arg)
|
|
871
|
+
except ValueError:
|
|
872
|
+
self.notify(f"'{arg}' isn't a valid number, or 'off'.", severity="warning")
|
|
873
|
+
return
|
|
874
|
+
if value <= 0:
|
|
875
|
+
self.notify(
|
|
876
|
+
"Budget must be greater than 0 (use /budget off to clear it).", severity="warning"
|
|
877
|
+
)
|
|
878
|
+
return
|
|
879
|
+
|
|
880
|
+
self._settings.max_session_cost_usd = value
|
|
881
|
+
update_config_file(max_session_cost_usd=value)
|
|
882
|
+
self.notify(
|
|
883
|
+
f"max_session_cost_usd set to ${value:.2f} (spent so far: ${spent:.4f}) — takes "
|
|
884
|
+
"effect on the next round-trip."
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
def _handle_context_limit_command(self, arg: str | None) -> None:
|
|
888
|
+
"""`/context-limit [tokens]` — sets or shows the context-window size
|
|
889
|
+
pcli assumes for the current model (ContextLimitTable, cost/context.py).
|
|
890
|
+
Wrong by default for any model without a built-in or user-configured
|
|
891
|
+
entry (silently falls back to a generic 128000-token guess), which
|
|
892
|
+
disables auto-compaction for a model with a much smaller real
|
|
893
|
+
window — see the context-ceiling notice in _run_one_turn, which
|
|
894
|
+
points here. Persists to context_limits.toml and reloads the table
|
|
895
|
+
immediately, so it takes effect without a restart. A toast, not a
|
|
896
|
+
transcript message - same "glance and maybe retype" reasoning as
|
|
897
|
+
/timeout above."""
|
|
898
|
+
model = self._session.model or self._settings.default_model
|
|
899
|
+
if not model:
|
|
900
|
+
self.notify("No model configured to set a context limit for.", severity="warning")
|
|
901
|
+
return
|
|
902
|
+
|
|
903
|
+
if not arg:
|
|
904
|
+
current = self._context_limit_table.lookup(model)
|
|
905
|
+
self.notify(
|
|
906
|
+
f"Assumed context limit for '{model}': {current:,} tokens. Usage: "
|
|
907
|
+
"/context-limit <tokens>"
|
|
908
|
+
)
|
|
909
|
+
return
|
|
910
|
+
|
|
911
|
+
try:
|
|
912
|
+
limit = int(arg)
|
|
913
|
+
except ValueError:
|
|
914
|
+
self.notify(f"'{arg}' isn't a valid number of tokens.", severity="warning")
|
|
915
|
+
return
|
|
916
|
+
if limit <= 0:
|
|
917
|
+
self.notify("Context limit must be greater than 0.", severity="warning")
|
|
918
|
+
return
|
|
919
|
+
|
|
920
|
+
set_model_context_limit(model, limit)
|
|
921
|
+
self._context_limit_table = ContextLimitTable.load()
|
|
922
|
+
self.notify(f"Context limit for '{model}' set to {limit:,} tokens.")
|
|
923
|
+
|
|
924
|
+
def _handle_max_tool_iterations_command(self, arg: str | None) -> None:
|
|
925
|
+
"""`/max-tool-iterations [n]` — caps how many tool-call round-trips
|
|
926
|
+
a single turn can make before it's cut off (see AgentLoop.run_turn's
|
|
927
|
+
iteration guardrail). Ignored in local-api mode, which always runs
|
|
928
|
+
uncapped (_effective_max_tool_iterations returns None there) —
|
|
929
|
+
still saved for whenever local-api mode is off. A toast, not a
|
|
930
|
+
transcript message - same "glance and maybe retype" reasoning as
|
|
931
|
+
/timeout above."""
|
|
932
|
+
if not arg:
|
|
933
|
+
current = self._settings.max_tool_iterations
|
|
934
|
+
note = " (currently uncapped: local-api mode)" if self._settings.is_local_api() else ""
|
|
935
|
+
self.notify(
|
|
936
|
+
f"max_tool_iterations is currently {current}{note}. Usage: "
|
|
937
|
+
"/max-tool-iterations <n>"
|
|
938
|
+
)
|
|
939
|
+
return
|
|
940
|
+
|
|
941
|
+
try:
|
|
942
|
+
iterations = int(arg)
|
|
943
|
+
except ValueError:
|
|
944
|
+
self.notify(f"'{arg}' isn't a valid number of iterations.", severity="warning")
|
|
945
|
+
return
|
|
946
|
+
if iterations <= 0:
|
|
947
|
+
self.notify("max_tool_iterations must be greater than 0.", severity="warning")
|
|
948
|
+
return
|
|
949
|
+
|
|
950
|
+
self._settings.max_tool_iterations = iterations
|
|
951
|
+
update_config_file(max_tool_iterations=iterations)
|
|
952
|
+
if self._agent_loop is not None:
|
|
953
|
+
self._agent_loop.set_max_tool_iterations(self._effective_max_tool_iterations())
|
|
954
|
+
note = (
|
|
955
|
+
" This session is in local-api mode, so it stays uncapped until that changes."
|
|
956
|
+
if self._settings.is_local_api()
|
|
957
|
+
else " Takes effect on the next turn."
|
|
958
|
+
)
|
|
959
|
+
self.notify(f"max_tool_iterations set to {iterations}.{note}")
|
|
960
|
+
|
|
961
|
+
def _handle_artifact_threshold_command(self, arg: str | None) -> None:
|
|
962
|
+
"""`/artifact-threshold [chars]` — tool results longer than this are
|
|
963
|
+
truncated out of the live conversation and archived to the artifact
|
|
964
|
+
library, retrievable via fetch_artifact (see AgentLoop._archive_if_large
|
|
965
|
+
and Settings.artifact_threshold_chars). Persisted the same way
|
|
966
|
+
/timeout persists request_timeout_s. A toast, not a transcript
|
|
967
|
+
message - same "glance and maybe retype" reasoning as /timeout
|
|
968
|
+
above."""
|
|
969
|
+
if not arg:
|
|
970
|
+
current = self._settings.artifact_threshold_chars
|
|
971
|
+
self.notify(
|
|
972
|
+
f"artifact_threshold_chars is currently {current:,}. Usage: "
|
|
973
|
+
"/artifact-threshold <chars>"
|
|
974
|
+
)
|
|
975
|
+
return
|
|
976
|
+
|
|
977
|
+
try:
|
|
978
|
+
threshold = int(arg)
|
|
979
|
+
except ValueError:
|
|
980
|
+
self.notify(f"'{arg}' isn't a valid number of characters.", severity="warning")
|
|
981
|
+
return
|
|
982
|
+
if threshold <= 0:
|
|
983
|
+
self.notify("artifact_threshold_chars must be greater than 0.", severity="warning")
|
|
984
|
+
return
|
|
985
|
+
|
|
986
|
+
self._settings.artifact_threshold_chars = threshold
|
|
987
|
+
update_config_file(artifact_threshold_chars=threshold)
|
|
988
|
+
if self._agent_loop is not None:
|
|
989
|
+
self._agent_loop.set_artifact_threshold_chars(threshold)
|
|
990
|
+
self.notify(f"artifact_threshold_chars set to {threshold:,}. Takes effect on the next tool result.")
|
|
991
|
+
|
|
992
|
+
def _handle_guardrail_rate_limit_command(
|
|
993
|
+
self, arg: str | None, *, attr_name: str, config_key: str, command_name: str, window: str
|
|
994
|
+
) -> None:
|
|
995
|
+
"""Shared by `/max-tool-calls-per-turn` and `/max-tool-calls-per-minute`
|
|
996
|
+
— both view/set the same-shaped guardrails.toml [limits] key
|
|
997
|
+
(`GuardrailsConfig`, `src/pcli/permissions/guardrails.py`), where 0
|
|
998
|
+
means unlimited (see AgentLoop.run_turn's per-turn check and
|
|
999
|
+
PermissionManager._within_rate_limit's per-minute check).
|
|
1000
|
+
|
|
1001
|
+
In local-api mode, ChatScreen.__init__ already force-copies both of
|
|
1002
|
+
these to 0 (unlimited) for the live guardrails instance — a new
|
|
1003
|
+
value is still persisted here for whenever local-api mode is off,
|
|
1004
|
+
but isn't applied live, so the response says as much rather than
|
|
1005
|
+
implying it silently took effect. A toast, not a transcript
|
|
1006
|
+
message - same "glance and maybe retype" reasoning as /timeout
|
|
1007
|
+
above."""
|
|
1008
|
+
guardrails = self._permission_manager.guardrails
|
|
1009
|
+
if not arg:
|
|
1010
|
+
current = getattr(guardrails, attr_name)
|
|
1011
|
+
if self._settings.is_local_api():
|
|
1012
|
+
note = " (currently forced unlimited: local-api mode)"
|
|
1013
|
+
elif current <= 0:
|
|
1014
|
+
note = " (0 = unlimited)"
|
|
1015
|
+
else:
|
|
1016
|
+
note = ""
|
|
1017
|
+
self.notify(
|
|
1018
|
+
f"{config_key} is currently {current}{note}. Usage: /{command_name} <n> (0 = unlimited)"
|
|
1019
|
+
)
|
|
1020
|
+
return
|
|
1021
|
+
|
|
1022
|
+
try:
|
|
1023
|
+
value = int(arg)
|
|
1024
|
+
except ValueError:
|
|
1025
|
+
self.notify(f"'{arg}' isn't a valid number.", severity="warning")
|
|
1026
|
+
return
|
|
1027
|
+
if value < 0:
|
|
1028
|
+
self.notify(f"{config_key} must be 0 or greater (0 means unlimited).", severity="warning")
|
|
1029
|
+
return
|
|
1030
|
+
|
|
1031
|
+
update_guardrails_limits(**{config_key: value})
|
|
1032
|
+
if self._settings.is_local_api():
|
|
1033
|
+
note = (
|
|
1034
|
+
" This session is in local-api mode, so it stays unlimited until that changes."
|
|
1035
|
+
)
|
|
1036
|
+
else:
|
|
1037
|
+
setattr(guardrails, attr_name, value)
|
|
1038
|
+
note = f" Takes effect on the next {window}."
|
|
1039
|
+
self.notify(f"{config_key} set to {value}.{note}")
|
|
1040
|
+
|
|
1041
|
+
def _handle_allowed_roots_command(self, rest: str) -> None:
|
|
1042
|
+
"""`/allowed-roots [add|remove] <path>` — view or live-edit
|
|
1043
|
+
guardrails.toml's [fs] allowed_roots list (permissions/
|
|
1044
|
+
guardrails.py's evaluate_path, gating read_file/write_file/
|
|
1045
|
+
edit_file/list_dir/glob_search/grep/diff_files/apply_patch/
|
|
1046
|
+
download_file). A path outside every allowed_roots entry is a hard
|
|
1047
|
+
guardrail deny — checked before, and never overridable by, a
|
|
1048
|
+
permission prompt (see the system prompt's "Respecting
|
|
1049
|
+
guardrails") — this command is the actual, intended way to widen
|
|
1050
|
+
what the agent can reach, not something to route around via a
|
|
1051
|
+
prompt. Mirrors _handle_guardrail_rate_limit_command's persist-
|
|
1052
|
+
then-hot-reload shape, but for a list-valued [fs] key instead of a
|
|
1053
|
+
scalar [limits] one. A toast, not a transcript message - same
|
|
1054
|
+
"glance and maybe retype" reasoning as /timeout above."""
|
|
1055
|
+
guardrails = self._permission_manager.guardrails
|
|
1056
|
+
sub_command, _, arg = rest.partition(" ")
|
|
1057
|
+
sub_command = sub_command.strip().lower()
|
|
1058
|
+
arg = arg.strip()
|
|
1059
|
+
|
|
1060
|
+
if not sub_command:
|
|
1061
|
+
roots = "\n".join(f"- {root}" for root in guardrails.fs_allowed_roots)
|
|
1062
|
+
self.notify(
|
|
1063
|
+
f"Current allowed_roots:\n{roots}\n\nUsage: /allowed-roots add <path> | "
|
|
1064
|
+
"/allowed-roots remove <path>"
|
|
1065
|
+
)
|
|
1066
|
+
return
|
|
1067
|
+
|
|
1068
|
+
if sub_command == "add":
|
|
1069
|
+
if not arg:
|
|
1070
|
+
self.notify("Usage: /allowed-roots add <path>", severity="warning")
|
|
1071
|
+
return
|
|
1072
|
+
if arg in guardrails.fs_allowed_roots:
|
|
1073
|
+
self.notify(f"'{arg}' is already in allowed_roots.", severity="warning")
|
|
1074
|
+
return
|
|
1075
|
+
new_roots = [*guardrails.fs_allowed_roots, arg]
|
|
1076
|
+
update_guardrails_fs_allowed_roots(new_roots)
|
|
1077
|
+
guardrails.fs_allowed_roots = new_roots
|
|
1078
|
+
self.notify(f"Added '{arg}' to allowed_roots. Takes effect immediately.")
|
|
1079
|
+
return
|
|
1080
|
+
|
|
1081
|
+
if sub_command == "remove":
|
|
1082
|
+
if not arg:
|
|
1083
|
+
self.notify("Usage: /allowed-roots remove <path>", severity="warning")
|
|
1084
|
+
return
|
|
1085
|
+
if arg not in guardrails.fs_allowed_roots:
|
|
1086
|
+
self.notify(f"'{arg}' isn't in allowed_roots.", severity="warning")
|
|
1087
|
+
return
|
|
1088
|
+
if len(guardrails.fs_allowed_roots) == 1:
|
|
1089
|
+
self.notify(
|
|
1090
|
+
"Refusing to remove the last allowed_roots entry — the agent needs at "
|
|
1091
|
+
"least one, or every filesystem tool call would be denied.",
|
|
1092
|
+
severity="warning",
|
|
1093
|
+
)
|
|
1094
|
+
return
|
|
1095
|
+
new_roots = [root for root in guardrails.fs_allowed_roots if root != arg]
|
|
1096
|
+
update_guardrails_fs_allowed_roots(new_roots)
|
|
1097
|
+
guardrails.fs_allowed_roots = new_roots
|
|
1098
|
+
self.notify(f"Removed '{arg}' from allowed_roots. Takes effect immediately.")
|
|
1099
|
+
return
|
|
1100
|
+
|
|
1101
|
+
self.notify(
|
|
1102
|
+
f"Unknown /allowed-roots subcommand: '{sub_command}'. Use /allowed-roots, "
|
|
1103
|
+
"/allowed-roots add <path>, or /allowed-roots remove <path>.",
|
|
1104
|
+
severity="warning",
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
def _handle_subagent_command(self) -> None:
|
|
1108
|
+
"""`/subagent` — a one-off snapshot of the currently-running
|
|
1109
|
+
subagent's task and its full tool-call history so far (name +
|
|
1110
|
+
arguments), plus any question it's currently blocked on, logged as a
|
|
1111
|
+
system message in the chat transcript. The status bar's second line
|
|
1112
|
+
only has room for a one-line summary (last tool name); Ctrl+G
|
|
1113
|
+
(action_show_subagent_activity) opens the same detail as a
|
|
1114
|
+
live-updating panel instead of a static snapshot, for anyone
|
|
1115
|
+
actively watching rather than checking in once. The "nothing
|
|
1116
|
+
running" case is a toast - trivially re-checked by rerunning the
|
|
1117
|
+
command; the activity dump itself stays in the transcript since
|
|
1118
|
+
it's genuine reference content worth scrolling back to."""
|
|
1119
|
+
sub = self._activity.subagent
|
|
1120
|
+
if sub is None:
|
|
1121
|
+
self.notify(_NO_SUBAGENT_RUNNING_MESSAGE)
|
|
1122
|
+
return
|
|
1123
|
+
self.query_one(MessageView).add_message("system", format_subagent_activity(sub))
|
|
1124
|
+
|
|
1125
|
+
def _handle_memory_command(self, rest: str) -> None:
|
|
1126
|
+
"""`/memory` — lists pcli's global, cross-session memory of this
|
|
1127
|
+
user (nature of work, preferences, conversation style, common asks -
|
|
1128
|
+
see memory/models.py); `/memory forget <id>` removes one entry by
|
|
1129
|
+
its short id (last 4 chars, as shown in the listing, same
|
|
1130
|
+
abbreviation SessionListScreen uses for session ids); `/memory
|
|
1131
|
+
clear` wipes it. This is the transparency/control surface for
|
|
1132
|
+
memory/extraction.py's autonomous derivation - what got remembered
|
|
1133
|
+
should always be visible and correctable, never a silent background
|
|
1134
|
+
process. The no-arg listing stays in the transcript (reference
|
|
1135
|
+
content worth scrolling back to, same as /toolbox list); every
|
|
1136
|
+
other branch here is a toast - a quick confirmation or error,
|
|
1137
|
+
trivially re-checked by rerunning /memory."""
|
|
1138
|
+
sub_command, _, arg = rest.partition(" ")
|
|
1139
|
+
sub_command = sub_command.strip().lower()
|
|
1140
|
+
arg = arg.strip()
|
|
1141
|
+
|
|
1142
|
+
if not sub_command:
|
|
1143
|
+
self.query_one(MessageView).add_message(
|
|
1144
|
+
"system", render_memory_list(read_memory().entries)
|
|
1145
|
+
)
|
|
1146
|
+
return
|
|
1147
|
+
|
|
1148
|
+
if sub_command == "clear":
|
|
1149
|
+
clear_memory()
|
|
1150
|
+
self.notify("Cleared all memory entries.")
|
|
1151
|
+
return
|
|
1152
|
+
|
|
1153
|
+
if sub_command == "forget":
|
|
1154
|
+
if not arg:
|
|
1155
|
+
self.notify("Usage: /memory forget <id>", severity="warning")
|
|
1156
|
+
return
|
|
1157
|
+
matches = [e for e in read_memory().entries if e.id.endswith(arg)]
|
|
1158
|
+
if not matches:
|
|
1159
|
+
self.notify(f"No memory entry found matching '{arg}'.", severity="warning")
|
|
1160
|
+
return
|
|
1161
|
+
if len(matches) > 1:
|
|
1162
|
+
self.notify(
|
|
1163
|
+
f"'{arg}' matches more than one entry — use a longer id.",
|
|
1164
|
+
severity="warning",
|
|
1165
|
+
)
|
|
1166
|
+
return
|
|
1167
|
+
remove_entry(matches[0].id)
|
|
1168
|
+
self.notify(f"Forgot: {matches[0].content}")
|
|
1169
|
+
return
|
|
1170
|
+
|
|
1171
|
+
self.notify(
|
|
1172
|
+
f"Unknown /memory subcommand: '{sub_command}'. Use /memory, /memory forget "
|
|
1173
|
+
"<id>, or /memory clear.",
|
|
1174
|
+
severity="warning",
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
def action_show_subagent_activity(self) -> None:
|
|
1178
|
+
"""Ctrl+G — opens a live-updating view of the current subagent's
|
|
1179
|
+
task/tool-call history (SubagentActivityModal), or just reports
|
|
1180
|
+
there's nothing running yet, same message /subagent gives (also a
|
|
1181
|
+
toast, same reasoning)."""
|
|
1182
|
+
if self._activity.subagent is None:
|
|
1183
|
+
self.notify(_NO_SUBAGENT_RUNNING_MESSAGE)
|
|
1184
|
+
return
|
|
1185
|
+
self.app.push_screen(SubagentActivityModal(self._activity))
|
|
1186
|
+
|
|
1187
|
+
def _handle_prune_tool_results_command(self, arg: str | None) -> None:
|
|
1188
|
+
"""`/prune-tool-results [off|on|<n>]` — view/toggle/set the
|
|
1189
|
+
no-LLM-call tool-result pruning pass (see
|
|
1190
|
+
agent/context_pruning.py's prune_old_tool_results, run from
|
|
1191
|
+
_run_one_turn). No-arg reports the current enabled state and
|
|
1192
|
+
keep_recent_turns. `off`/`on` toggles prune_tool_results_enabled.
|
|
1193
|
+
A positive integer sets prune_tool_results_keep_recent_turns and
|
|
1194
|
+
implicitly re-enables it. Persisted to config.toml the same way
|
|
1195
|
+
/timeout persists request_timeout_s; applied live since
|
|
1196
|
+
_run_one_turn reads these settings fresh every turn. A toast, not a
|
|
1197
|
+
transcript message - same "glance and maybe retype" reasoning as
|
|
1198
|
+
/timeout above."""
|
|
1199
|
+
if not arg:
|
|
1200
|
+
state = "enabled" if self._settings.prune_tool_results_enabled else "disabled"
|
|
1201
|
+
self.notify(
|
|
1202
|
+
f"prune_tool_results is {state}, keeping the most recent "
|
|
1203
|
+
f"{self._settings.prune_tool_results_keep_recent_turns} turn(s) verbatim. "
|
|
1204
|
+
"Usage: /prune-tool-results off|on|<n>"
|
|
1205
|
+
)
|
|
1206
|
+
return
|
|
1207
|
+
|
|
1208
|
+
if arg == "off":
|
|
1209
|
+
self._settings.prune_tool_results_enabled = False
|
|
1210
|
+
update_config_file(prune_tool_results_enabled=False)
|
|
1211
|
+
self.notify("prune_tool_results disabled.")
|
|
1212
|
+
return
|
|
1213
|
+
|
|
1214
|
+
if arg == "on":
|
|
1215
|
+
self._settings.prune_tool_results_enabled = True
|
|
1216
|
+
update_config_file(prune_tool_results_enabled=True)
|
|
1217
|
+
self.notify("prune_tool_results enabled.")
|
|
1218
|
+
return
|
|
1219
|
+
|
|
1220
|
+
try:
|
|
1221
|
+
keep_recent_turns = int(arg)
|
|
1222
|
+
except ValueError:
|
|
1223
|
+
self.notify(f"'{arg}' isn't 'off', 'on', or a valid number.", severity="warning")
|
|
1224
|
+
return
|
|
1225
|
+
if keep_recent_turns <= 0:
|
|
1226
|
+
self.notify(
|
|
1227
|
+
"keep_recent_turns must be greater than 0 (use /prune-tool-results off to "
|
|
1228
|
+
"disable pruning entirely).",
|
|
1229
|
+
severity="warning",
|
|
1230
|
+
)
|
|
1231
|
+
return
|
|
1232
|
+
|
|
1233
|
+
self._settings.prune_tool_results_enabled = True
|
|
1234
|
+
self._settings.prune_tool_results_keep_recent_turns = keep_recent_turns
|
|
1235
|
+
update_config_file(
|
|
1236
|
+
prune_tool_results_enabled=True,
|
|
1237
|
+
prune_tool_results_keep_recent_turns=keep_recent_turns,
|
|
1238
|
+
)
|
|
1239
|
+
self.notify(
|
|
1240
|
+
f"prune_tool_results_keep_recent_turns set to {keep_recent_turns}. "
|
|
1241
|
+
"Takes effect on the next turn."
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
def _handle_max_response_tokens_command(self, arg: str | None) -> None:
|
|
1245
|
+
"""`/max-response-tokens [off|on|<n>]` — view/toggle/set the dynamic
|
|
1246
|
+
per-request max_tokens cap (compute_max_response_tokens,
|
|
1247
|
+
cost/context.py), computed fresh before every turn in
|
|
1248
|
+
_run_one_turn so a single response can't consume the entire
|
|
1249
|
+
remaining context window by itself — see that function's docstring
|
|
1250
|
+
for the real session this fixes. No-arg reports the enabled state,
|
|
1251
|
+
the configured safety margin, and what the cap would currently
|
|
1252
|
+
compute to. `off`/`on` toggles max_response_tokens_enabled. A
|
|
1253
|
+
positive integer sets max_response_tokens_safety_margin (tokens of
|
|
1254
|
+
headroom reserved below the model's context limit) and implicitly
|
|
1255
|
+
re-enables it. Persisted to config.toml, applied live. A toast, not
|
|
1256
|
+
a transcript message - same "glance and maybe retype" reasoning as
|
|
1257
|
+
/timeout above."""
|
|
1258
|
+
if not arg:
|
|
1259
|
+
state = "enabled" if self._settings.max_response_tokens_enabled else "disabled"
|
|
1260
|
+
current = compute_max_response_tokens(
|
|
1261
|
+
self._session,
|
|
1262
|
+
limit_table=self._context_limit_table,
|
|
1263
|
+
safety_margin=self._settings.max_response_tokens_safety_margin,
|
|
1264
|
+
)
|
|
1265
|
+
current_text = f"{current:,} tokens" if current is not None else "no cap (not yet computable)"
|
|
1266
|
+
self.notify(
|
|
1267
|
+
f"max_response_tokens is {state}, safety margin "
|
|
1268
|
+
f"{self._settings.max_response_tokens_safety_margin:,} tokens. Would currently "
|
|
1269
|
+
f"send max_tokens={current_text}. Usage: /max-response-tokens off|on|<margin>"
|
|
1270
|
+
)
|
|
1271
|
+
return
|
|
1272
|
+
|
|
1273
|
+
if arg == "off":
|
|
1274
|
+
self._settings.max_response_tokens_enabled = False
|
|
1275
|
+
update_config_file(max_response_tokens_enabled=False)
|
|
1276
|
+
self.notify("max_response_tokens disabled.")
|
|
1277
|
+
return
|
|
1278
|
+
|
|
1279
|
+
if arg == "on":
|
|
1280
|
+
self._settings.max_response_tokens_enabled = True
|
|
1281
|
+
update_config_file(max_response_tokens_enabled=True)
|
|
1282
|
+
self.notify("max_response_tokens enabled.")
|
|
1283
|
+
return
|
|
1284
|
+
|
|
1285
|
+
try:
|
|
1286
|
+
safety_margin = int(arg)
|
|
1287
|
+
except ValueError:
|
|
1288
|
+
self.notify(f"'{arg}' isn't 'off', 'on', or a valid number.", severity="warning")
|
|
1289
|
+
return
|
|
1290
|
+
if safety_margin <= 0:
|
|
1291
|
+
self.notify(
|
|
1292
|
+
"safety margin must be greater than 0 (use /max-response-tokens off to "
|
|
1293
|
+
"disable the cap entirely).",
|
|
1294
|
+
severity="warning",
|
|
1295
|
+
)
|
|
1296
|
+
return
|
|
1297
|
+
|
|
1298
|
+
self._settings.max_response_tokens_enabled = True
|
|
1299
|
+
self._settings.max_response_tokens_safety_margin = safety_margin
|
|
1300
|
+
update_config_file(
|
|
1301
|
+
max_response_tokens_enabled=True,
|
|
1302
|
+
max_response_tokens_safety_margin=safety_margin,
|
|
1303
|
+
)
|
|
1304
|
+
self.notify(
|
|
1305
|
+
f"max_response_tokens_safety_margin set to {safety_margin:,}. Takes effect on the "
|
|
1306
|
+
"next turn."
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
def _handle_rename_command(self, arg: str | None) -> None:
|
|
1310
|
+
"""`/rename [name]` — sets Session.title, which derive_title() (used
|
|
1311
|
+
by the /sessions list) prefers over the auto-derived first-message
|
|
1312
|
+
snippet. No-arg shows the current title. A toast, not a transcript
|
|
1313
|
+
message - same "glance and maybe retype" reasoning as /timeout
|
|
1314
|
+
above."""
|
|
1315
|
+
if not arg:
|
|
1316
|
+
self.notify(f"Current session title: '{self._session.derive_title()}'. Usage: /rename <name>")
|
|
1317
|
+
return
|
|
1318
|
+
|
|
1319
|
+
self._session.title = arg
|
|
1320
|
+
self._store.save(self._session)
|
|
1321
|
+
self.notify(f"Session renamed to '{arg}'.")
|
|
1322
|
+
|
|
1323
|
+
def _handle_theme_command(self, arg: str | None) -> None:
|
|
1324
|
+
"""`/theme [name]` — App.theme is the actual switch (Textual repaints
|
|
1325
|
+
every CSS design-token-based style live, no restart needed); this
|
|
1326
|
+
just persists the choice the same way /rename persists a title, so
|
|
1327
|
+
it's remembered next time (see PcliApp.on_mount, which applies
|
|
1328
|
+
settings.ui_theme at startup). No-arg lists every registered theme
|
|
1329
|
+
(Textual's own builtins plus pcli's vim-* ones from tui/themes.py),
|
|
1330
|
+
marking the active one — also the fallback shown for an unknown
|
|
1331
|
+
name, rather than silently doing nothing. The set-confirmation is a
|
|
1332
|
+
toast (same "glance and maybe retype" reasoning as /timeout above);
|
|
1333
|
+
the listing branches stay in the transcript since they dump the
|
|
1334
|
+
full theme list, same reasoning as /memory's/`/toolbox list`'s own
|
|
1335
|
+
listing output."""
|
|
1336
|
+
message_view = self.query_one(MessageView)
|
|
1337
|
+
available = sorted(self.app.available_themes)
|
|
1338
|
+
current = self.app.theme
|
|
1339
|
+
|
|
1340
|
+
if arg and arg in self.app.available_themes:
|
|
1341
|
+
self.app.theme = arg
|
|
1342
|
+
self._settings.ui_theme = arg
|
|
1343
|
+
update_config_file(ui_theme=arg)
|
|
1344
|
+
self.notify(f"Theme set to '{arg}'.")
|
|
1345
|
+
return
|
|
1346
|
+
|
|
1347
|
+
listing = "\n".join(f"- `{name}`{' (active)' if name == current else ''}" for name in available)
|
|
1348
|
+
prefix = f"Unknown theme '{arg}'.\n\n" if arg else ""
|
|
1349
|
+
message_view.add_message(
|
|
1350
|
+
"system", f"{prefix}**Available themes:**\n{listing}\n\nUsage: /theme <name>"
|
|
1351
|
+
)
|
|
1352
|
+
|
|
1353
|
+
def _set_plan_mode(self, enabled: bool) -> None:
|
|
1354
|
+
"""`/plan` (enter) / `/build` (exit) — restricts the agent to
|
|
1355
|
+
plan_mode_safe tools (read/explore only, no writes/edits/shell). The
|
|
1356
|
+
registry swap is the primary mechanism (the model never even sees a
|
|
1357
|
+
disallowed tool); AgentLoop's dispatch-time backstop and the
|
|
1358
|
+
per-turn prompt reinforcement (agent/prompt.py's
|
|
1359
|
+
PLAN_MODE_REINFORCEMENT, shared with headless.py's own plan-mode
|
|
1360
|
+
support) are the additional layers on top, not the boundary itself."""
|
|
1361
|
+
message_view = self.query_one(MessageView)
|
|
1362
|
+
if enabled == self._plan_mode:
|
|
1363
|
+
message_view.add_message(
|
|
1364
|
+
"system", f"Already in {'plan' if enabled else 'build'} mode."
|
|
1365
|
+
)
|
|
1366
|
+
return
|
|
1367
|
+
|
|
1368
|
+
self._plan_mode = enabled
|
|
1369
|
+
self.query_one(StatusBar).plan_mode = enabled
|
|
1370
|
+
if self._agent_loop is not None:
|
|
1371
|
+
self._agent_loop.set_tool_registry(self._effective_tool_registry())
|
|
1372
|
+
|
|
1373
|
+
if enabled:
|
|
1374
|
+
message_view.add_message(
|
|
1375
|
+
"system",
|
|
1376
|
+
"Plan mode enabled: only read-only/exploration tools are available. "
|
|
1377
|
+
"Use /build to exit and allow writes/edits/shell commands again.",
|
|
1378
|
+
)
|
|
1379
|
+
else:
|
|
1380
|
+
message_view.add_message("system", "Build mode restored: all tools are available again.")
|
|
1381
|
+
|
|
1382
|
+
def _handle_help_command(self) -> None:
|
|
1383
|
+
"""`/help` — a static reference of every slash command and shell
|
|
1384
|
+
prefix, rendered as Markdown (message_view._render_message already
|
|
1385
|
+
runs every system message through rich.markdown.Markdown)."""
|
|
1386
|
+
message_view = self.query_one(MessageView)
|
|
1387
|
+
message_view.add_message("system", _HELP_TEXT)
|
|
1388
|
+
|
|
1389
|
+
def _handle_toolbox_command(self, rest: str) -> None:
|
|
1390
|
+
sub, _, arg = rest.partition(" ")
|
|
1391
|
+
arg = arg.strip()
|
|
1392
|
+
|
|
1393
|
+
if sub == "discover" and arg:
|
|
1394
|
+
# /toolbox discover <name> [path] - a second token registers a
|
|
1395
|
+
# self-authored script directly, bypassing PATH lookup (same
|
|
1396
|
+
# path= parameter register_toolbox_tool gives the LLM itself).
|
|
1397
|
+
name, _, path = arg.partition(" ")
|
|
1398
|
+
self._toolbox_discover(name, path.strip() or None)
|
|
1399
|
+
elif sub == "list":
|
|
1400
|
+
self._toolbox_list()
|
|
1401
|
+
elif sub == "remove" and arg:
|
|
1402
|
+
self._toolbox_remove(arg)
|
|
1403
|
+
else:
|
|
1404
|
+
self.notify(
|
|
1405
|
+
"Usage: /toolbox discover <name> [path] | /toolbox list | /toolbox remove <name>",
|
|
1406
|
+
severity="warning",
|
|
1407
|
+
)
|
|
1408
|
+
|
|
1409
|
+
@work(exclusive=True)
|
|
1410
|
+
async def _toolbox_discover(self, name: str, path: str | None = None) -> None:
|
|
1411
|
+
"""Discovery's own failure and success summary stay in the
|
|
1412
|
+
transcript (genuine, potentially detailed reference content); the
|
|
1413
|
+
"isn't available" guard and the "Discovering..." progress ping are
|
|
1414
|
+
toasts - the ping is superseded within moments by one of the two
|
|
1415
|
+
transcript outcomes above, so keeping it around permanently would
|
|
1416
|
+
just be a stray duplicate."""
|
|
1417
|
+
message_view = self.query_one(MessageView)
|
|
1418
|
+
if self._toolbox_manager is None:
|
|
1419
|
+
self.notify("Toolbox isn't available (gateway/sandbox not set up).", severity="warning")
|
|
1420
|
+
return
|
|
1421
|
+
self.notify(f"Discovering '{name}'...")
|
|
1422
|
+
try:
|
|
1423
|
+
summary = await self._toolbox_manager.discover(
|
|
1424
|
+
name, gateway_client=self._client, model=self._settings.default_model or None, path=path
|
|
1425
|
+
)
|
|
1426
|
+
except (ToolboxDiscoveryError, GatewayError, SandboxSecurityError) as exc:
|
|
1427
|
+
# discover() calls the gateway to synthesize tool schemas when
|
|
1428
|
+
# there's no curated plugin (toolbox/manager.py) - that call can
|
|
1429
|
+
# raise GatewayError same as any other, and it previously wasn't
|
|
1430
|
+
# caught here at all, crashing this worker silently.
|
|
1431
|
+
message_view.add_message("system", f"Discovery failed: {exc}")
|
|
1432
|
+
return
|
|
1433
|
+
message_view.add_message("system", summary)
|
|
1434
|
+
|
|
1435
|
+
if self._tool_registry is not None:
|
|
1436
|
+
loaded = await self._toolbox_manager.load_all()
|
|
1437
|
+
self._tool_registry.merge(loaded)
|
|
1438
|
+
|
|
1439
|
+
@work(exclusive=True)
|
|
1440
|
+
async def _toolbox_list(self) -> None:
|
|
1441
|
+
if self._toolbox_manager is None:
|
|
1442
|
+
self.notify("Toolbox isn't available (gateway/sandbox not set up).", severity="warning")
|
|
1443
|
+
return
|
|
1444
|
+
entries = self._toolbox_manager.list_discovered()
|
|
1445
|
+
if not entries:
|
|
1446
|
+
self.notify("No software discovered yet. Try /toolbox discover <name>.")
|
|
1447
|
+
return
|
|
1448
|
+
lines = [
|
|
1449
|
+
f"- **{name}** [{entry['source']}] {entry.get('version', '?')} - "
|
|
1450
|
+
f"{entry.get('tool_count', 0)} tool(s)"
|
|
1451
|
+
for name, entry in entries.items()
|
|
1452
|
+
]
|
|
1453
|
+
self.query_one(MessageView).add_message("system", "\n".join(lines))
|
|
1454
|
+
|
|
1455
|
+
@work(exclusive=True)
|
|
1456
|
+
async def _toolbox_remove(self, name: str) -> None:
|
|
1457
|
+
if self._toolbox_manager is None:
|
|
1458
|
+
self.notify("Toolbox isn't available (gateway/sandbox not set up).", severity="warning")
|
|
1459
|
+
return
|
|
1460
|
+
self._toolbox_manager.remove(name)
|
|
1461
|
+
self.notify(f"Removed '{name}' from the toolbox.")
|
|
1462
|
+
|
|
1463
|
+
@work(exclusive=True)
|
|
1464
|
+
async def _handle_models_command(self, arg: str | None) -> None:
|
|
1465
|
+
""""Failed to list models" stays in the transcript as a real,
|
|
1466
|
+
investigable failure; everything else here is a toast - a quick
|
|
1467
|
+
confirmation or a state the user can just retry."""
|
|
1468
|
+
if arg:
|
|
1469
|
+
self._set_model(arg)
|
|
1470
|
+
self.notify(f"Model set to '{arg}'.")
|
|
1471
|
+
return
|
|
1472
|
+
|
|
1473
|
+
if not self._settings.gateway_base_url:
|
|
1474
|
+
self.notify(
|
|
1475
|
+
"No gateway URL configured. Set PCLI_GATEWAY_URL and restart pcli.",
|
|
1476
|
+
severity="warning",
|
|
1477
|
+
)
|
|
1478
|
+
return
|
|
1479
|
+
|
|
1480
|
+
client = self._client
|
|
1481
|
+
owns_client = client is None
|
|
1482
|
+
if client is None:
|
|
1483
|
+
client = GatewayClient(self._settings)
|
|
1484
|
+
|
|
1485
|
+
try:
|
|
1486
|
+
models = await client.list_models()
|
|
1487
|
+
except GatewayError as exc:
|
|
1488
|
+
self.query_one(MessageView).add_message("system", f"Failed to list models: {exc.message}")
|
|
1489
|
+
return
|
|
1490
|
+
finally:
|
|
1491
|
+
if owns_client:
|
|
1492
|
+
await client.aclose()
|
|
1493
|
+
|
|
1494
|
+
if not models:
|
|
1495
|
+
self.notify("Gateway returned no models.", severity="warning")
|
|
1496
|
+
return
|
|
1497
|
+
|
|
1498
|
+
from pcli.tui.screens.models import ModelListScreen
|
|
1499
|
+
|
|
1500
|
+
current = self._settings.default_model or self._session.model or None
|
|
1501
|
+
selected = await self.app.push_screen_wait(ModelListScreen(models, current))
|
|
1502
|
+
if selected:
|
|
1503
|
+
self._set_model(selected)
|
|
1504
|
+
self.notify(f"Model set to '{selected}'.")
|
|
1505
|
+
|
|
1506
|
+
def _set_model(self, model: str) -> None:
|
|
1507
|
+
self._settings.default_model = model
|
|
1508
|
+
self._session.model = model
|
|
1509
|
+
if self._agent_loop is not None:
|
|
1510
|
+
self._agent_loop.set_model(model)
|
|
1511
|
+
self.query_one(StatusBar).model = model
|
|
1512
|
+
# Remembered for next time so a bare `pcli` picks it up.
|
|
1513
|
+
update_config_file(default_model=model)
|
|
1514
|
+
|
|
1515
|
+
@work(exclusive=True)
|
|
1516
|
+
async def _export_current(self, out_path_arg: str | None) -> None:
|
|
1517
|
+
"""A toast, not a transcript message - same "glance and maybe
|
|
1518
|
+
retype" reasoning as /timeout above; the exported path is right
|
|
1519
|
+
there in the notification and the file itself is the durable
|
|
1520
|
+
record, not this confirmation."""
|
|
1521
|
+
from pcli.config.paths import data_dir
|
|
1522
|
+
|
|
1523
|
+
out_path = (
|
|
1524
|
+
Path(out_path_arg).expanduser()
|
|
1525
|
+
if out_path_arg
|
|
1526
|
+
else data_dir() / "exports" / f"{self._session.id}.pcli-session.json"
|
|
1527
|
+
)
|
|
1528
|
+
export_session(self._session, out_path, store=self._store)
|
|
1529
|
+
self.notify(f"Exported session to {out_path}")
|
|
1530
|
+
|
|
1531
|
+
def _record_tool_invocation(self, event: ToolResultEvent) -> None:
|
|
1532
|
+
"""Delegates to agent/runtime.py's record_tool_invocation, shared
|
|
1533
|
+
with headless/scheduled runs (run_headless_task) so every caller
|
|
1534
|
+
records identically - this method used to duplicate that logic
|
|
1535
|
+
TUI-only."""
|
|
1536
|
+
record_tool_invocation(
|
|
1537
|
+
self._session, event, audit_enabled=self._settings.audit_mode_enabled
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
def _effective_compaction_model(self) -> str | None:
|
|
1541
|
+
"""The model compaction summarization and memory extraction should
|
|
1542
|
+
use - settings.compaction_model if configured (a deliberately
|
|
1543
|
+
cheaper/smaller model for these mechanical, lower-stakes background
|
|
1544
|
+
calls), falling back to the same default_model/session.model chain
|
|
1545
|
+
every other call site here uses when it isn't."""
|
|
1546
|
+
return (
|
|
1547
|
+
self._settings.compaction_model
|
|
1548
|
+
or self._settings.default_model
|
|
1549
|
+
or self._session.model
|
|
1550
|
+
or None
|
|
1551
|
+
)
|
|
1552
|
+
|
|
1553
|
+
def _show_decision_notice(self, event: ToolResultEvent) -> None:
|
|
1554
|
+
"""record_decision results are shown as a distinct, always-visible
|
|
1555
|
+
message (not the generic collapsed-by-default tool Collapsible) —
|
|
1556
|
+
the whole point of the decision log is that it's immediately
|
|
1557
|
+
scannable, not tucked away."""
|
|
1558
|
+
self._render_decision_notice(event.tool_call.function.arguments)
|
|
1559
|
+
|
|
1560
|
+
def _render_decision_notice(self, arguments_json: str) -> None:
|
|
1561
|
+
"""Shared with _replay_message_history, which reconstructs this same
|
|
1562
|
+
notice for a reloaded session from the original record_decision
|
|
1563
|
+
call's arguments rather than re-deriving it some other way."""
|
|
1564
|
+
try:
|
|
1565
|
+
arguments = json.loads(arguments_json or "{}")
|
|
1566
|
+
except json.JSONDecodeError:
|
|
1567
|
+
arguments = {}
|
|
1568
|
+
decision = arguments.get("decision", "") if isinstance(arguments, dict) else ""
|
|
1569
|
+
rationale = arguments.get("rationale", "") if isinstance(arguments, dict) else ""
|
|
1570
|
+
self.query_one(MessageView).add_message("decision", f"**{decision}**\n\n{rationale}")
|
|
1571
|
+
|
|
1572
|
+
def _replay_message_history(self) -> None:
|
|
1573
|
+
"""Rebuilds the visible transcript from session.messages using the
|
|
1574
|
+
same rendering helpers a live turn uses (add_tool_call/
|
|
1575
|
+
add_tool_result/_render_decision_notice) — a real reported gap:
|
|
1576
|
+
the previous reload logic only ever showed user/assistant .content
|
|
1577
|
+
and a flat, hard-truncated-to-500-chars line per tool result, so a
|
|
1578
|
+
tool-call-only assistant turn (content=None, tool_calls set) vanished
|
|
1579
|
+
entirely, tool results lost their syntax-highlighted/collapsible
|
|
1580
|
+
rendering, and a mid-conversation system message (notably a
|
|
1581
|
+
compaction summary, which replaces old turns in session.messages
|
|
1582
|
+
itself) wasn't shown at all. Purely cosmetic either way —
|
|
1583
|
+
session.messages itself (what's actually resent to the LLM) is
|
|
1584
|
+
untouched regardless of how it's displayed here.
|
|
1585
|
+
|
|
1586
|
+
One real, unavoidable gap: whether a given past tool call actually
|
|
1587
|
+
errored isn't retained on Message itself (only in
|
|
1588
|
+
Session.tool_invocations, which - unlike session.messages - is never
|
|
1589
|
+
pruned/compacted, so it can't be reliably correlated back to a
|
|
1590
|
+
specific reconstructed message once either has happened). Every
|
|
1591
|
+
reconstructed tool result therefore renders as if it succeeded, even
|
|
1592
|
+
if the original call actually failed."""
|
|
1593
|
+
message_view = self.query_one(MessageView)
|
|
1594
|
+
messages = self._session.messages
|
|
1595
|
+
skip_leading_system_prompt = bool(messages) and messages[0].role == "system"
|
|
1596
|
+
pending_calls: dict[str, ToolCall] = {}
|
|
1597
|
+
|
|
1598
|
+
for index, message in enumerate(messages):
|
|
1599
|
+
if index == 0 and skip_leading_system_prompt:
|
|
1600
|
+
continue
|
|
1601
|
+
if message.role in ("system", "user") and message.content:
|
|
1602
|
+
message_view.add_message(message.role, message.content)
|
|
1603
|
+
elif message.role == "assistant":
|
|
1604
|
+
if message.content:
|
|
1605
|
+
message_view.add_message("assistant", message.content)
|
|
1606
|
+
for call in message.tool_calls or []:
|
|
1607
|
+
pending_calls[call.id] = call
|
|
1608
|
+
purpose = extract_purpose(call.function.arguments)
|
|
1609
|
+
message_view.add_tool_call(
|
|
1610
|
+
call.function.name, call.function.arguments, purpose=purpose
|
|
1611
|
+
)
|
|
1612
|
+
elif message.role == "tool" and message.content:
|
|
1613
|
+
originating_call = pending_calls.pop(message.tool_call_id or "", None)
|
|
1614
|
+
if message.name == "record_decision" and originating_call is not None:
|
|
1615
|
+
self._render_decision_notice(originating_call.function.arguments)
|
|
1616
|
+
else:
|
|
1617
|
+
message_view.add_tool_result(message.name or "tool", message.content, is_error=False)
|
|
1618
|
+
|
|
1619
|
+
async def _run_compaction(self, reason: str) -> None:
|
|
1620
|
+
"""Summarizes and archives the oldest turns of session.messages (see
|
|
1621
|
+
agent/compaction.py) — either after an "auto" trigger from
|
|
1622
|
+
_stream_response, or a "manual" /compact command. Plain async method
|
|
1623
|
+
(not @work) so _stream_response can just await it directly while
|
|
1624
|
+
already inside its own worker; /compact reaches it via the small
|
|
1625
|
+
@work-wrapped _manual_compact below, same pattern as every other
|
|
1626
|
+
synchronously-dispatched command in this file. The "isn't
|
|
1627
|
+
available"/"still working"/"nothing to compact yet" guards below
|
|
1628
|
+
are toasts - trivially re-checked by rerunning /compact; a real
|
|
1629
|
+
failure and the actual success notice both stay in the transcript
|
|
1630
|
+
as genuine, investigable records."""
|
|
1631
|
+
message_view = self.query_one(MessageView)
|
|
1632
|
+
status_bar = self.query_one(StatusBar)
|
|
1633
|
+
if self._client is None or self._agent_loop is None:
|
|
1634
|
+
if reason == "manual":
|
|
1635
|
+
self.notify(
|
|
1636
|
+
"Compaction isn't available (gateway/sandbox not set up).",
|
|
1637
|
+
severity="warning",
|
|
1638
|
+
)
|
|
1639
|
+
return
|
|
1640
|
+
|
|
1641
|
+
if reason == "manual" and self._turn_in_progress:
|
|
1642
|
+
# maybe_compact slices/replaces session.messages directly —
|
|
1643
|
+
# running it concurrently with an active turn (which also reads
|
|
1644
|
+
# and appends to that same list) could corrupt the conversation.
|
|
1645
|
+
# The "auto" trigger is never at risk of this: it only ever runs
|
|
1646
|
+
# sequentially, awaited from inside _run_one_turn itself, after
|
|
1647
|
+
# that turn has already finished.
|
|
1648
|
+
self.notify(
|
|
1649
|
+
"Still working on the current turn — try /compact again once it's done.",
|
|
1650
|
+
severity="warning",
|
|
1651
|
+
)
|
|
1652
|
+
return
|
|
1653
|
+
|
|
1654
|
+
configured_keep_recent_turns = self._settings.auto_compact_keep_recent_turns
|
|
1655
|
+
tightened_to: int | None = None
|
|
1656
|
+
status_bar.busy = True
|
|
1657
|
+
try:
|
|
1658
|
+
# Regression guard: this used to only cover the maybe_compact
|
|
1659
|
+
# call(s) below, not the bookkeeping/memory-extraction that
|
|
1660
|
+
# follows a successful one - "Working..." would disappear the
|
|
1661
|
+
# instant summarization finished, then reappear once memory
|
|
1662
|
+
# extraction's own (invisible) gateway call finally completed,
|
|
1663
|
+
# looking exactly like pcli had silently stalled in between.
|
|
1664
|
+
# Covering the whole function in one busy=True/False window
|
|
1665
|
+
# fixes that: the indicator now stays lit for everything this
|
|
1666
|
+
# method actually does, not just its first step.
|
|
1667
|
+
try:
|
|
1668
|
+
result = await maybe_compact(
|
|
1669
|
+
self._session,
|
|
1670
|
+
gateway_client=self._client,
|
|
1671
|
+
model=self._effective_compaction_model(),
|
|
1672
|
+
artifact_store=self._artifact_store,
|
|
1673
|
+
keep_recent_turns=configured_keep_recent_turns,
|
|
1674
|
+
)
|
|
1675
|
+
if result is None:
|
|
1676
|
+
# The configured recent-turns window can itself be
|
|
1677
|
+
# what's filling context - e.g. several truncation/
|
|
1678
|
+
# auto-continue retries (each its own turn boundary,
|
|
1679
|
+
# see compaction.py's turn_boundaries) concentrated
|
|
1680
|
+
# inside the protected window, with everything older
|
|
1681
|
+
# already compacted away by an earlier pass. Only retry
|
|
1682
|
+
# with a smaller window when there's genuine pressure
|
|
1683
|
+
# to free room - a small/fresh session at low usage
|
|
1684
|
+
# should still just report "nothing to compact" rather
|
|
1685
|
+
# than having its only turns eaten unnecessarily.
|
|
1686
|
+
usage = current_context_usage(
|
|
1687
|
+
self._session, limit_table=self._context_limit_table
|
|
1688
|
+
)
|
|
1689
|
+
if usage.fraction >= self._settings.auto_compact_threshold:
|
|
1690
|
+
for smaller in range(configured_keep_recent_turns - 1, -1, -1):
|
|
1691
|
+
result = await maybe_compact(
|
|
1692
|
+
self._session,
|
|
1693
|
+
gateway_client=self._client,
|
|
1694
|
+
model=self._effective_compaction_model(),
|
|
1695
|
+
artifact_store=self._artifact_store,
|
|
1696
|
+
keep_recent_turns=smaller,
|
|
1697
|
+
)
|
|
1698
|
+
if result is not None:
|
|
1699
|
+
tightened_to = smaller
|
|
1700
|
+
break
|
|
1701
|
+
except GatewayError as exc:
|
|
1702
|
+
# Previously uncaught here: maybe_compact's one
|
|
1703
|
+
# summarization call failing (e.g. a timeout) would crash
|
|
1704
|
+
# straight out of this worker with nothing shown to the
|
|
1705
|
+
# user - the turn that triggered auto-compaction had
|
|
1706
|
+
# already completed and saved successfully by this point,
|
|
1707
|
+
# so this is a notice, not a lost turn, but it still needs
|
|
1708
|
+
# to be visible (context usage just silently won't have
|
|
1709
|
+
# shrunk).
|
|
1710
|
+
logger.exception("Gateway error during compaction: %s", exc.message)
|
|
1711
|
+
message_view.add_message("system", f"Compaction failed: {exc.message}")
|
|
1712
|
+
return
|
|
1713
|
+
|
|
1714
|
+
if result is None:
|
|
1715
|
+
if reason == "manual":
|
|
1716
|
+
self.notify("Nothing to compact yet.")
|
|
1717
|
+
return
|
|
1718
|
+
|
|
1719
|
+
# Reuses ToolInvocation.full_result_ref purely so export_session
|
|
1720
|
+
# (which only bundles blobs it finds referenced there) carries
|
|
1721
|
+
# this artifact along too — no real tool call happened.
|
|
1722
|
+
self._session.tool_invocations.append(
|
|
1723
|
+
ToolInvocation(
|
|
1724
|
+
tool_name="_compaction",
|
|
1725
|
+
arguments={},
|
|
1726
|
+
status="ok",
|
|
1727
|
+
result_summary=f"Compacted {result.messages_compacted} message(s).",
|
|
1728
|
+
full_result_ref=SessionArtifactStore.blob_name_for(result.artifact_id),
|
|
1729
|
+
)
|
|
1730
|
+
)
|
|
1731
|
+
# Real spend, so it counts toward cost — but tagged
|
|
1732
|
+
# source="compaction" so current_context_usage/
|
|
1733
|
+
# compute_max_response_tokens (which look for the last *main*-
|
|
1734
|
+
# conversation entry, not just the literal last one) aren't
|
|
1735
|
+
# misled into thinking the main conversation is however big
|
|
1736
|
+
# this one summarization call's own prompt happened to be. Also
|
|
1737
|
+
# deliberately not fed into _refresh_context_display, for the
|
|
1738
|
+
# same reason subagent usage isn't - the status bar
|
|
1739
|
+
# self-corrects on the next real turn's usage report.
|
|
1740
|
+
self._cost_tracker.record_turn(
|
|
1741
|
+
self._effective_compaction_model() or "",
|
|
1742
|
+
result.usage,
|
|
1743
|
+
source="compaction",
|
|
1744
|
+
)
|
|
1745
|
+
self._refresh_cost_display(status_bar)
|
|
1746
|
+
tightened_note = (
|
|
1747
|
+
f" (kept only the last {tightened_to} recent turn(s) verbatim instead of the "
|
|
1748
|
+
f"usual {configured_keep_recent_turns} — context was still full at that setting)"
|
|
1749
|
+
if tightened_to is not None
|
|
1750
|
+
else ""
|
|
1751
|
+
)
|
|
1752
|
+
message_view.add_message(
|
|
1753
|
+
"system",
|
|
1754
|
+
f"Compacted {result.messages_compacted} earlier message(s) to reduce context "
|
|
1755
|
+
f"usage (archived as artifact_id='{result.artifact_id}'){tightened_note}.",
|
|
1756
|
+
)
|
|
1757
|
+
self._store.save(self._session)
|
|
1758
|
+
|
|
1759
|
+
if self._settings.memory_enabled:
|
|
1760
|
+
await self._extract_memory_from(result.artifact_id, status_bar)
|
|
1761
|
+
finally:
|
|
1762
|
+
status_bar.busy = False
|
|
1763
|
+
|
|
1764
|
+
async def _extract_memory_from(self, artifact_id: str, status_bar: StatusBar) -> None:
|
|
1765
|
+
"""Reviews the exact transcript maybe_compact just archived for
|
|
1766
|
+
anything durable/cross-session-worthy (memory/extraction.py) -
|
|
1767
|
+
piggybacked on compaction rather than a separate trigger, since a
|
|
1768
|
+
session substantial enough to need compacting is also substantial
|
|
1769
|
+
enough to be worth learning from, and this reuses the transcript
|
|
1770
|
+
already sitting in the artifact store instead of re-deriving it.
|
|
1771
|
+
Best-effort: a failure here is logged and otherwise invisible - it
|
|
1772
|
+
never touched the turn that triggered compaction, and losing one
|
|
1773
|
+
extraction pass isn't worth interrupting the user over."""
|
|
1774
|
+
transcript = self._artifact_store.get(artifact_id)
|
|
1775
|
+
if not transcript:
|
|
1776
|
+
return
|
|
1777
|
+
model = self._effective_compaction_model()
|
|
1778
|
+
try:
|
|
1779
|
+
extraction_ctx = replace(self._make_tool_context(), model=model)
|
|
1780
|
+
usages = await extract_memory(transcript, extraction_ctx)
|
|
1781
|
+
except GatewayError as exc:
|
|
1782
|
+
logger.exception("Gateway error during memory extraction: %s", exc.message)
|
|
1783
|
+
return
|
|
1784
|
+
if not usages:
|
|
1785
|
+
return
|
|
1786
|
+
for usage in usages:
|
|
1787
|
+
self._cost_tracker.record_turn(model or "", usage, source="memory")
|
|
1788
|
+
self._refresh_cost_display(status_bar)
|
|
1789
|
+
self._store.save(self._session)
|
|
1790
|
+
|
|
1791
|
+
@work(exclusive=True)
|
|
1792
|
+
async def _manual_compact(self) -> None:
|
|
1793
|
+
await self._run_compaction("manual")
|
|
1794
|
+
|
|
1795
|
+
@work(exclusive=True, group="agent-turn")
|
|
1796
|
+
async def _stream_response(self) -> None:
|
|
1797
|
+
"""Runs turns back-to-back for as long as new user input gets
|
|
1798
|
+
queued while the previous one was still in flight (see
|
|
1799
|
+
on_input_submitted) — matches how opencode/Claude Code let you
|
|
1800
|
+
queue follow-up messages instead of either blocking input or
|
|
1801
|
+
cancelling the in-flight turn.
|
|
1802
|
+
|
|
1803
|
+
Given its own dedicated worker group so no other exclusive worker
|
|
1804
|
+
on this screen (compaction, /export, /toolbox, ...) can ever cancel
|
|
1805
|
+
it: they used to all share Textual's default group, and Textual
|
|
1806
|
+
cancels every other worker in a group whenever a new exclusive one
|
|
1807
|
+
in that same group starts — so submitting a second chat message (or
|
|
1808
|
+
any other command) while a turn was running silently killed it
|
|
1809
|
+
instead of queueing or running after."""
|
|
1810
|
+
self._turn_in_progress = True
|
|
1811
|
+
try:
|
|
1812
|
+
while True:
|
|
1813
|
+
await self._run_one_turn()
|
|
1814
|
+
if self._queued_followups:
|
|
1815
|
+
# The turn that just finished has already appended its
|
|
1816
|
+
# own assistant reply to session.messages (turn_complete,
|
|
1817
|
+
# inside _run_one_turn) - only now is it safe to append
|
|
1818
|
+
# what the user typed while that was still in flight, so
|
|
1819
|
+
# it lands after that reply instead of before it.
|
|
1820
|
+
for text in self._queued_followups:
|
|
1821
|
+
self._session.messages.append(Message(role="user", content=text))
|
|
1822
|
+
self._queued_followups = []
|
|
1823
|
+
self._store.save(self._session)
|
|
1824
|
+
self._has_queued_followup = False
|
|
1825
|
+
continue
|
|
1826
|
+
if not self._has_queued_followup:
|
|
1827
|
+
break
|
|
1828
|
+
self._has_queued_followup = False
|
|
1829
|
+
finally:
|
|
1830
|
+
self._turn_in_progress = False
|
|
1831
|
+
|
|
1832
|
+
async def _prune_and_maybe_auto_compact(self) -> None:
|
|
1833
|
+
"""Cheap, mechanical, no-LLM-call pruning first (so compaction's own
|
|
1834
|
+
summarization call has less bulk to work with by the time its
|
|
1835
|
+
threshold is reached), then a real auto-compaction pass if context
|
|
1836
|
+
is still over auto_compact_threshold afterward. Factored out so the
|
|
1837
|
+
exact same recovery can run from _run_one_turn's GatewayError
|
|
1838
|
+
handler, not just its normal end-of-turn path — see that call site
|
|
1839
|
+
for why: a turn that fails outright because context was already
|
|
1840
|
+
full previously skipped this entirely (an early `return` inside the
|
|
1841
|
+
except block), so a session over threshold stayed over threshold and
|
|
1842
|
+
every subsequent turn failed the exact same way, with no compaction
|
|
1843
|
+
ever attempted despite the status bar having shown the overflow."""
|
|
1844
|
+
if self._settings.prune_tool_results_enabled:
|
|
1845
|
+
pruned_count = prune_old_tool_results(
|
|
1846
|
+
self._session,
|
|
1847
|
+
keep_recent_turns=self._settings.prune_tool_results_keep_recent_turns,
|
|
1848
|
+
artifact_store=self._artifact_store,
|
|
1849
|
+
)
|
|
1850
|
+
if pruned_count:
|
|
1851
|
+
self._store.save(self._session)
|
|
1852
|
+
|
|
1853
|
+
if self._settings.auto_compact_enabled:
|
|
1854
|
+
usage = current_context_usage(self._session, limit_table=self._context_limit_table)
|
|
1855
|
+
if usage.fraction >= self._settings.auto_compact_threshold:
|
|
1856
|
+
await self._run_compaction("auto")
|
|
1857
|
+
|
|
1858
|
+
async def _run_one_turn(self) -> None:
|
|
1859
|
+
assert self._agent_loop is not None
|
|
1860
|
+
message_view = self.query_one(MessageView)
|
|
1861
|
+
status_bar = self.query_one(StatusBar)
|
|
1862
|
+
message_view.add_message("assistant", "")
|
|
1863
|
+
|
|
1864
|
+
async def ask(tool_name: str, arguments: dict, risk_description: str):
|
|
1865
|
+
return await ask_via_modal(self.app, tool_name, arguments, risk_description)
|
|
1866
|
+
|
|
1867
|
+
async def ask_question(question: str, options: list[str] | None) -> str:
|
|
1868
|
+
return await ask_question_via_modal(self.app, question, options)
|
|
1869
|
+
|
|
1870
|
+
self._current_ask = ask
|
|
1871
|
+
self._current_ask_question = ask_question
|
|
1872
|
+
status_bar.busy = True
|
|
1873
|
+
status_bar.main_tool_calls = 0
|
|
1874
|
+
status_bar.main_tool_calls_limit = self._permission_manager.guardrails.max_tool_calls_per_turn
|
|
1875
|
+
|
|
1876
|
+
# Reasoning models stream their chain-of-thought under a channel
|
|
1877
|
+
# separate from the actual reply (see llm/streaming.py) — buffered
|
|
1878
|
+
# here and flushed as a collapsed "Thinking" panel whenever real
|
|
1879
|
+
# content or a tool call follows, rather than shown live: a model
|
|
1880
|
+
# can spend its *entire* response reasoning without ever producing
|
|
1881
|
+
# visible text or a tool call, which previously looked exactly like
|
|
1882
|
+
# pcli doing nothing at all. had_any_content tracks that case so a
|
|
1883
|
+
# clear notice can be shown instead of a silently empty turn.
|
|
1884
|
+
reasoning_parts: list[str] = []
|
|
1885
|
+
had_any_content = False
|
|
1886
|
+
had_any_reasoning = False
|
|
1887
|
+
response_truncated = False
|
|
1888
|
+
|
|
1889
|
+
def flush_reasoning() -> None:
|
|
1890
|
+
nonlocal had_any_reasoning
|
|
1891
|
+
if reasoning_parts:
|
|
1892
|
+
had_any_reasoning = True
|
|
1893
|
+
message_view.add_reasoning("".join(reasoning_parts))
|
|
1894
|
+
reasoning_parts.clear()
|
|
1895
|
+
|
|
1896
|
+
if self._settings.max_response_tokens_enabled:
|
|
1897
|
+
self._agent_loop.set_max_response_tokens(
|
|
1898
|
+
compute_max_response_tokens(
|
|
1899
|
+
self._session,
|
|
1900
|
+
limit_table=self._context_limit_table,
|
|
1901
|
+
safety_margin=self._settings.max_response_tokens_safety_margin,
|
|
1902
|
+
)
|
|
1903
|
+
)
|
|
1904
|
+
else:
|
|
1905
|
+
self._agent_loop.set_max_response_tokens(None)
|
|
1906
|
+
|
|
1907
|
+
try:
|
|
1908
|
+
chat_messages = [m.to_chat_message() for m in self._session.messages]
|
|
1909
|
+
if self._plan_mode:
|
|
1910
|
+
chat_messages.append(ChatMessage(role="system", content=PLAN_MODE_REINFORCEMENT))
|
|
1911
|
+
async for chunk in self._agent_loop.run_turn(
|
|
1912
|
+
chat_messages,
|
|
1913
|
+
ask=ask,
|
|
1914
|
+
budget_check=lambda: cost_budget_reason(
|
|
1915
|
+
self._session, self._settings.max_session_cost_usd
|
|
1916
|
+
),
|
|
1917
|
+
):
|
|
1918
|
+
if chunk.kind == "text_delta":
|
|
1919
|
+
had_any_content = True
|
|
1920
|
+
flush_reasoning()
|
|
1921
|
+
message_view.append_to_last(chunk.text)
|
|
1922
|
+
elif chunk.kind == "reasoning_delta":
|
|
1923
|
+
reasoning_parts.append(chunk.text)
|
|
1924
|
+
elif chunk.kind == "usage":
|
|
1925
|
+
self._cost_tracker.record_turn(
|
|
1926
|
+
self._settings.default_model or self._session.model, chunk.usage
|
|
1927
|
+
)
|
|
1928
|
+
self._refresh_cost_display(status_bar)
|
|
1929
|
+
self._refresh_context_display(status_bar, chunk.usage)
|
|
1930
|
+
if self._context_limit_retry_pending:
|
|
1931
|
+
# The model just produced a real response, so a
|
|
1932
|
+
# local gateway that JIT-loads (LM Studio) must have
|
|
1933
|
+
# it loaded now, even if it didn't at startup - one
|
|
1934
|
+
# retry, not a per-turn habit (see the flag's own
|
|
1935
|
+
# comment in __init__).
|
|
1936
|
+
self._context_limit_retry_pending = False
|
|
1937
|
+
await self._maybe_detect_context_limit(message_view, is_retry=True)
|
|
1938
|
+
elif chunk.kind == "tool_start":
|
|
1939
|
+
had_any_content = True
|
|
1940
|
+
flush_reasoning()
|
|
1941
|
+
message_view.finish_streaming()
|
|
1942
|
+
purpose = extract_purpose(chunk.tool_call.function.arguments)
|
|
1943
|
+
message_view.add_tool_call(
|
|
1944
|
+
chunk.tool_call.function.name,
|
|
1945
|
+
chunk.tool_call.function.arguments,
|
|
1946
|
+
purpose=purpose,
|
|
1947
|
+
)
|
|
1948
|
+
message_view.finish_streaming()
|
|
1949
|
+
elif chunk.kind == "tool_result":
|
|
1950
|
+
self._record_tool_invocation(chunk)
|
|
1951
|
+
status_bar.main_tool_calls += 1
|
|
1952
|
+
if chunk.tool_call.function.name == "write_todos":
|
|
1953
|
+
self._refresh_todo_pane()
|
|
1954
|
+
# source="subagent": real spend from a nested AgentLoop
|
|
1955
|
+
# (spawn_subagent/explore_*/etc.), not the main
|
|
1956
|
+
# conversation - see the "compaction" note above for why
|
|
1957
|
+
# this tag matters, not just that it counts toward cost.
|
|
1958
|
+
for extra in chunk.extra_usage:
|
|
1959
|
+
self._cost_tracker.record_turn(
|
|
1960
|
+
self._settings.default_model or self._session.model,
|
|
1961
|
+
extra,
|
|
1962
|
+
source="subagent",
|
|
1963
|
+
)
|
|
1964
|
+
if chunk.extra_usage:
|
|
1965
|
+
self._refresh_cost_display(status_bar)
|
|
1966
|
+
if chunk.tool_call.function.name == "record_decision" and not chunk.is_error:
|
|
1967
|
+
self._show_decision_notice(chunk)
|
|
1968
|
+
else:
|
|
1969
|
+
message_view.add_tool_result(
|
|
1970
|
+
chunk.tool_call.function.name, chunk.output, is_error=chunk.is_error
|
|
1971
|
+
)
|
|
1972
|
+
message_view.finish_streaming()
|
|
1973
|
+
elif chunk.kind == "turn_complete":
|
|
1974
|
+
self._session.messages.extend(
|
|
1975
|
+
Message.from_chat_message(m) for m in chunk.new_messages
|
|
1976
|
+
)
|
|
1977
|
+
response_truncated = chunk.response_truncated
|
|
1978
|
+
except GatewayError as exc:
|
|
1979
|
+
# Previously the only trace of this was a message that vanished
|
|
1980
|
+
# the moment the TUI closed — nothing was logged, and nothing
|
|
1981
|
+
# was saved, so a session that failed on its very first turn
|
|
1982
|
+
# left literally no record anywhere (not even the user's own
|
|
1983
|
+
# message). Log it (with traceback) and save whatever's already
|
|
1984
|
+
# in self._session.messages (system prompt, prior successful
|
|
1985
|
+
# turns, and the user message that triggered this attempt) so a
|
|
1986
|
+
# failed turn is actually debuggable afterward.
|
|
1987
|
+
logger.exception("Gateway error during turn: %s", exc.message)
|
|
1988
|
+
flush_reasoning()
|
|
1989
|
+
message_view.finish_streaming()
|
|
1990
|
+
message_view.add_message(
|
|
1991
|
+
"system",
|
|
1992
|
+
f"Gateway error: {exc.message}\n\n"
|
|
1993
|
+
"**Note:** the message you just sent is still part of this session and will "
|
|
1994
|
+
"be resent as context on your next turn - a failed attempt doesn't discard "
|
|
1995
|
+
"it, it isn't silently skipped.",
|
|
1996
|
+
)
|
|
1997
|
+
self._store.save(self._session)
|
|
1998
|
+
# A GatewayError this far into a turn is often exactly a context-
|
|
1999
|
+
# length overflow (see llm/client.py's _http_status_hint) - the
|
|
2000
|
+
# status bar may well have shown context usage crossing 100% on
|
|
2001
|
+
# an earlier "usage" chunk of this same turn, right before the
|
|
2002
|
+
# next internal call got rejected for being too large. Without
|
|
2003
|
+
# this, the oversized history that caused the failure would
|
|
2004
|
+
# never get compacted (the normal end-of-turn call below is
|
|
2005
|
+
# never reached on this path), so every subsequent turn would
|
|
2006
|
+
# fail the exact same way. Safe to attempt unconditionally: it's
|
|
2007
|
+
# a no-op unless context is actually still over threshold, and
|
|
2008
|
+
# _run_compaction already handles its own GatewayError quietly.
|
|
2009
|
+
await self._prune_and_maybe_auto_compact()
|
|
2010
|
+
return
|
|
2011
|
+
finally:
|
|
2012
|
+
status_bar.busy = False
|
|
2013
|
+
flush_reasoning()
|
|
2014
|
+
message_view.finish_streaming()
|
|
2015
|
+
if not had_any_content:
|
|
2016
|
+
# Not an error - the model responded, just with nothing visible
|
|
2017
|
+
# (e.g. it used its whole response budget on reasoning above and
|
|
2018
|
+
# never got to an actual answer or tool call). Previously this
|
|
2019
|
+
# looked identical to pcli having silently failed to do anything.
|
|
2020
|
+
suffix = ' (see "Thinking" above)' if had_any_reasoning else ""
|
|
2021
|
+
note = f"The model didn't produce a reply or tool call this turn{suffix}."
|
|
2022
|
+
if looks_like_context_ceiling(self._session):
|
|
2023
|
+
# A real debugged case: pcli's assumed context limit for this
|
|
2024
|
+
# model was wrong (silently falling back to a generic 128k
|
|
2025
|
+
# guess), so the fraction-based auto-compact trigger never
|
|
2026
|
+
# fired even though the session had actually exhausted the
|
|
2027
|
+
# model's real, much smaller window. total_tokens plateauing
|
|
2028
|
+
# near its highest-ever value for this session, turn after
|
|
2029
|
+
# turn, is the tell — see looks_like_context_ceiling.
|
|
2030
|
+
assumed = self._context_limit_table.lookup(self._session.model)
|
|
2031
|
+
note += (
|
|
2032
|
+
f" This looks like it may have hit the model's real context limit — pcli "
|
|
2033
|
+
f"is currently assuming {assumed:,} tokens for '{self._session.model}', "
|
|
2034
|
+
"which may be wrong. Try /context-limit <tokens> to correct it (so "
|
|
2035
|
+
"auto-compaction can kick in), or /compact to free up space now."
|
|
2036
|
+
)
|
|
2037
|
+
else:
|
|
2038
|
+
note += " Try again, or ask something more focused."
|
|
2039
|
+
message_view.add_message("system", note)
|
|
2040
|
+
self._store.save(self._session)
|
|
2041
|
+
|
|
2042
|
+
await self._prune_and_maybe_auto_compact()
|
|
2043
|
+
|
|
2044
|
+
if response_truncated:
|
|
2045
|
+
# Deliberately after pruning/auto-compact above, not before: if
|
|
2046
|
+
# context was tight enough to truncate this response in the
|
|
2047
|
+
# first place, freeing some up first gives the retry an actual
|
|
2048
|
+
# chance of finishing instead of just hitting the same wall.
|
|
2049
|
+
self._consecutive_truncations += 1
|
|
2050
|
+
if self._consecutive_truncations <= _MAX_CONSECUTIVE_AUTO_CONTINUES:
|
|
2051
|
+
message_view.add_message(
|
|
2052
|
+
"system",
|
|
2053
|
+
"Response was cut off by the token limit before finishing — continuing "
|
|
2054
|
+
f"automatically ({self._consecutive_truncations}/{_MAX_CONSECUTIVE_AUTO_CONTINUES}).",
|
|
2055
|
+
)
|
|
2056
|
+
self._session.messages.append(Message(role="user", content=_AUTO_CONTINUE_MESSAGE))
|
|
2057
|
+
message_view.add_message("user", _AUTO_CONTINUE_MESSAGE)
|
|
2058
|
+
self._store.save(self._session)
|
|
2059
|
+
self._has_queued_followup = True
|
|
2060
|
+
else:
|
|
2061
|
+
message_view.add_message(
|
|
2062
|
+
"system",
|
|
2063
|
+
f"Response was cut off by the token limit {_MAX_CONSECUTIVE_AUTO_CONTINUES} "
|
|
2064
|
+
"times in a row — stopping automatic continuation rather than risk a runaway "
|
|
2065
|
+
"loop. Try /max-response-tokens to raise the response-length cap, or just say "
|
|
2066
|
+
'"continue" to keep going manually.',
|
|
2067
|
+
)
|
|
2068
|
+
self._consecutive_truncations = 0
|
|
2069
|
+
else:
|
|
2070
|
+
self._consecutive_truncations = 0
|